diff --git a/app/app.py b/app/app.py index 4f959d5..6dddd19 100644 --- a/app/app.py +++ b/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: diff --git a/frontend/menu.html b/frontend/menu.html index 1ddc0a5..604299b 100644 --- a/frontend/menu.html +++ b/frontend/menu.html @@ -117,6 +117,40 @@ + +
+
+

Manage Files

+
+ + + +
+
+
+ + + + + + + + + + +
FolderFilesSizeModified
+
+
+
Admin link page
@@ -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 = `${fmtDayMonthForFiles(dir.modified)}`; + return ` + + ${dir.path} + ${dir.file_count} + ${humanSize(dir.total_size)} + ${modified} + `; + }).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 = ` +
+
+
+
Files
+ +
+
+ ${files.length ? `
` + files.map(it => { + const thumbUrl = it.is_image ? `/api/files/thumb/${it.path_b64}` : ''; + const fullUrl = `/api/files/full/${it.path_b64}`; + return ` + ${it.is_image ? + `` : + `` + } +
${it.name}
+
` + }).join('') + `
` : '
No files in this directory.
'} +
+
+
`; + + 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();