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 sqlite3
|
||||
import binascii
|
||||
import base64
|
||||
import mimetypes
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
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)
|
||||
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) ----------
|
||||
|
||||
def ensure_invites_table() -> None:
|
||||
|
||||
@@ -117,6 +117,40 @@
|
||||
</div>
|
||||
</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">
|
||||
Admin link page
|
||||
</section>
|
||||
@@ -441,6 +475,100 @@
|
||||
|
||||
// Initial load
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user