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:
2026-05-18 15:45:06 +00:00
parent 89b7b1bd23
commit ca5131f497
2 changed files with 285 additions and 0 deletions

View File

@@ -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:

View File

@@ -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 AZ</option>
<option value="-name">Name ZA</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('"','&quot;')}\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>