feat: Add admin file viewer with thumbnail generation and gallery
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
157
app/app.py
157
app/app.py
@@ -18,6 +18,8 @@ import hashlib
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import binascii
|
import binascii
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
import pytz
|
import pytz
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
@@ -1228,6 +1230,161 @@ async def api_albums_create(request: Request) -> JSONResponse:
|
|||||||
logger.exception("Create album directory failed: %s", e)
|
logger.exception("Create album directory failed: %s", e)
|
||||||
return JSONResponse({"error": "create_album_failed"}, status_code=500)
|
return JSONResponse({"error": "create_album_failed"}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- File Viewer APIs ----------
|
||||||
|
UPLOAD_ROOT = "./data/uploads"
|
||||||
|
|
||||||
|
def _is_safe_path(base, path):
|
||||||
|
"""Check that resolved path is under base path."""
|
||||||
|
try:
|
||||||
|
# After resolving symlinks, the path must be inside the base.
|
||||||
|
return os.path.realpath(path).startswith(os.path.realpath(base))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@app.get("/api/files/dirs")
|
||||||
|
async def api_files_dirs(request: Request):
|
||||||
|
if not request.session.get("accessToken"):
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
q = (request.query_params.get("q") or "").strip().lower()
|
||||||
|
sort = (request.query_params.get("sort") or "-modified").strip()
|
||||||
|
|
||||||
|
dirs = []
|
||||||
|
try:
|
||||||
|
os.makedirs(UPLOAD_ROOT, exist_ok=True)
|
||||||
|
for root, _, filenames in os.walk(UPLOAD_ROOT):
|
||||||
|
if not filenames:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dir_path = root
|
||||||
|
rel_path = os.path.relpath(dir_path, UPLOAD_ROOT)
|
||||||
|
|
||||||
|
if rel_path == '.':
|
||||||
|
continue
|
||||||
|
|
||||||
|
if q and q not in rel_path.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_size = sum(os.path.getsize(os.path.join(dir_path, f)) for f in filenames)
|
||||||
|
file_mtimes = [os.path.getmtime(os.path.join(dir_path, f)) for f in filenames]
|
||||||
|
last_modified = max(file_mtimes) if file_mtimes else os.path.getmtime(dir_path)
|
||||||
|
|
||||||
|
dirs.append({
|
||||||
|
"path": rel_path,
|
||||||
|
"path_b64": base64.urlsafe_b64encode(rel_path.encode()).decode(),
|
||||||
|
"name": os.path.basename(dir_path),
|
||||||
|
"file_count": len(filenames),
|
||||||
|
"total_size": total_size,
|
||||||
|
"modified": last_modified
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to list directories: %s", e)
|
||||||
|
return JSONResponse({"error": "server_error"}, status_code=500)
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
reverse = sort.startswith('-')
|
||||||
|
sort_field = sort.lstrip('-+')
|
||||||
|
if sort_field not in ('name', 'file_count', 'total_size', 'modified'):
|
||||||
|
sort_field = 'modified'
|
||||||
|
|
||||||
|
dirs.sort(key=lambda x: x.get(sort_field, 0), reverse=reverse)
|
||||||
|
|
||||||
|
for d in dirs:
|
||||||
|
d['modified'] = datetime.fromtimestamp(d['modified']).isoformat()
|
||||||
|
|
||||||
|
return JSONResponse({"items": dirs})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/files/list/{path_b64}")
|
||||||
|
async def api_files_list(path_b64: str, request: Request):
|
||||||
|
if not request.session.get("accessToken"):
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||||
|
dir_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "invalid_path"}, status_code=400)
|
||||||
|
|
||||||
|
if not _is_safe_path(UPLOAD_ROOT, dir_path) or not os.path.isdir(dir_path):
|
||||||
|
return JSONResponse({"error": "not_found"}, status_code=404)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
try:
|
||||||
|
for filename in sorted(os.listdir(dir_path)):
|
||||||
|
file_path = os.path.join(dir_path, filename)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
mime_type, _ = mimetypes.guess_type(filename)
|
||||||
|
is_image = bool(mime_type and mime_type.startswith('image/'))
|
||||||
|
rel_file_path = os.path.relpath(file_path, UPLOAD_ROOT)
|
||||||
|
files.append({
|
||||||
|
"name": filename,
|
||||||
|
"path_b64": base64.urlsafe_b64encode(rel_file_path.encode()).decode(),
|
||||||
|
"size": os.path.getsize(file_path),
|
||||||
|
"modified": datetime.fromtimestamp(os.path.getmtime(file_path)).isoformat(),
|
||||||
|
"is_image": is_image,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to list files: %s", e)
|
||||||
|
return JSONResponse({"error": "server_error"}, status_code=500)
|
||||||
|
|
||||||
|
return JSONResponse({"items": files})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/files/thumb/{path_b64}")
|
||||||
|
async def api_files_thumb(path_b64: str, request: Request):
|
||||||
|
if not request.session.get("accessToken"):
|
||||||
|
return Response(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||||
|
file_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||||
|
except Exception:
|
||||||
|
return Response(status_code=400)
|
||||||
|
|
||||||
|
if not _is_safe_path(UPLOAD_ROOT, file_path) or not os.path.isfile(file_path):
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
mime_type, _ = mimetypes.guess_type(os.path.basename(file_path))
|
||||||
|
if not mime_type or not mime_type.startswith('image/'):
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
img.thumbnail((256, 256))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
# Convert to RGB to avoid issues with saving palette-based images (e.g. some GIFs) as JPEG
|
||||||
|
if img.mode not in ("RGB", "L"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(buf, format="JPEG", quality=85)
|
||||||
|
buf.seek(0)
|
||||||
|
return Response(content=buf.read(), media_type="image/jpeg")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to generate thumbnail for %s: %s", file_path, e)
|
||||||
|
return Response(status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/files/full/{path_b64}")
|
||||||
|
async def api_files_full(path_b64: str, request: Request):
|
||||||
|
if not request.session.get("accessToken"):
|
||||||
|
return Response(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||||
|
file_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||||
|
except Exception:
|
||||||
|
return Response(status_code=400)
|
||||||
|
|
||||||
|
if not _is_safe_path(UPLOAD_ROOT, file_path) or not os.path.isfile(file_path):
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
return FileResponse(file_path)
|
||||||
|
|
||||||
|
|
||||||
# ---------- Invites (one-time/expiring links) ----------
|
# ---------- Invites (one-time/expiring links) ----------
|
||||||
|
|
||||||
def ensure_invites_table() -> None:
|
def ensure_invites_table() -> None:
|
||||||
|
|||||||
@@ -117,6 +117,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Manage Files -->
|
||||||
|
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<h2 class="text-lg font-medium">Manage Files</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="filesSearchQ" placeholder="Search" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"/>
|
||||||
|
<select id="filesSortSel" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
|
||||||
|
<option value="-modified">Newest</option>
|
||||||
|
<option value="modified">Oldest</option>
|
||||||
|
<option value="name">Name A–Z</option>
|
||||||
|
<option value="-name">Name Z–A</option>
|
||||||
|
<option value="-file_count">File count desc</option>
|
||||||
|
<option value="file_count">File count asc</option>
|
||||||
|
<option value="-total_size">Size desc</option>
|
||||||
|
<option value="total_size">Size asc</option>
|
||||||
|
</select>
|
||||||
|
<button id="btnFilesRefresh" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left border-b dark:border-gray-700">
|
||||||
|
<th class="py-2" style="width: 50%;">Folder</th>
|
||||||
|
<th class="py-2">Files</th>
|
||||||
|
<th class="py-2">Size</th>
|
||||||
|
<th class="py-2">Modified</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="filesTBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="text-xs text-gray-500">
|
<section class="text-xs text-gray-500">
|
||||||
Admin link page
|
Admin link page
|
||||||
</section>
|
</section>
|
||||||
@@ -441,6 +475,100 @@
|
|||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadInvites();
|
loadInvites();
|
||||||
|
|
||||||
|
// --- Manage Files UI logic ---
|
||||||
|
const filesSearchQ = document.getElementById('filesSearchQ');
|
||||||
|
const filesSortSel = document.getElementById('filesSortSel');
|
||||||
|
const btnFilesRefresh = document.getElementById('btnFilesRefresh');
|
||||||
|
const filesTBody = document.getElementById('filesTBody');
|
||||||
|
let DIRS = [];
|
||||||
|
|
||||||
|
function humanSize(bytes){
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const k = 1024, sizes = ['B','KB','MB','GB','TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes)/Math.log(k));
|
||||||
|
return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i];
|
||||||
|
}
|
||||||
|
function fmtDayMonthForFiles(iso){ try{ const d = new Date(iso); return d.toLocaleDateString(undefined,{ day:'2-digit', month:'short' }); }catch{return '—';} }
|
||||||
|
|
||||||
|
|
||||||
|
async function loadDirs(){
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const q = (filesSearchQ.value||'').trim(); if (q) params.set('q', q);
|
||||||
|
const sort = (filesSortSel.value||'').trim(); if (sort) params.set('sort', sort);
|
||||||
|
try{
|
||||||
|
const r = await fetch('/api/files/dirs?'+params.toString());
|
||||||
|
const j = await r.json();
|
||||||
|
DIRS = (j && j.items) ? j.items : [];
|
||||||
|
} catch { DIRS = []; }
|
||||||
|
renderDirs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDirs(){
|
||||||
|
filesTBody.innerHTML = DIRS.map(dir => {
|
||||||
|
const modified = `<span title="${new Date(dir.modified).toLocaleString()}">${fmtDayMonthForFiles(dir.modified)}</span>`;
|
||||||
|
return `
|
||||||
|
<tr class="border-b dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer" data-path-b64="${dir.path_b64}">
|
||||||
|
<td class="py-2">${dir.path}</td>
|
||||||
|
<td class="py-2">${dir.file_count}</td>
|
||||||
|
<td class="py-2">${humanSize(dir.total_size)}</td>
|
||||||
|
<td class="py-2">${modified}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
filesTBody.querySelectorAll('tr').forEach(tr => {
|
||||||
|
tr.onclick = () => showGallery(tr.dataset.pathB64);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showGallery(path_b64) {
|
||||||
|
if (!path_b64) return;
|
||||||
|
let files = [];
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/files/list/${path_b64}`);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
const j = await r.json();
|
||||||
|
files = j.items || [];
|
||||||
|
} catch (e) {
|
||||||
|
showResult('err', 'Failed to load files: ' + String(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div class="max-w-6xl w-full h-[90vh] rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4 flex flex-col">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="text-lg font-medium">Files</div>
|
||||||
|
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
${files.length ? `<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">` + files.map(it => {
|
||||||
|
const thumbUrl = it.is_image ? `/api/files/thumb/${it.path_b64}` : '';
|
||||||
|
const fullUrl = `/api/files/full/${it.path_b64}`;
|
||||||
|
return `<a href="${fullUrl}" target="_blank" title="${it.name.replaceAll('"','"')}\n${humanSize(it.size)}" class="group relative aspect-square rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
|
${it.is_image ?
|
||||||
|
`<img src="${thumbUrl}" class="w-full h-full object-cover" loading="lazy"/>` :
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>`
|
||||||
|
}
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 truncate pointer-events-none">${it.name}</div>
|
||||||
|
</a>`
|
||||||
|
}).join('') + `</div>` : '<div class="text-sm text-gray-500">No files in this directory.</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
const dlg = wrap.firstElementChild;
|
||||||
|
document.body.appendChild(dlg);
|
||||||
|
dlg.querySelectorAll('.dlgClose').forEach(b => b.onclick = () => { try { dlg.remove(); } catch {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
btnFilesRefresh.onclick = loadDirs;
|
||||||
|
filesSearchQ.oninput = () => { clearTimeout(filesSearchQ._t); filesSearchQ._t = setTimeout(loadDirs, 300); };
|
||||||
|
filesSortSel.onchange = loadDirs;
|
||||||
|
|
||||||
|
loadDirs();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user