From 188e9a0dd6133f02116214c342783b40ac2bab9e Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Wed, 26 Aug 2026 15:30:15 +1000 Subject: [PATCH] Files page: thumbnails + view + download (email access to finished work) --- portal/main.py | 49 +++++++++++++++++++++++++++++++++++-- portal/templates/files.html | 12 ++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/portal/main.py b/portal/main.py index afacfcb..8567219 100644 --- a/portal/main.py +++ b/portal/main.py @@ -23,7 +23,7 @@ from typing import Annotated from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.concurrency import run_in_threadpool -from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.responses import HTMLResponse, RedirectResponse, Response, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import select @@ -237,10 +237,55 @@ def _shared_files(username: str) -> list: out = [] for f in sorted(user_dir.iterdir()): if f.is_file(): - out.append({"name": f.name, "size": f.stat().st_size}) + out.append({"name": f.name, "size": f.stat().st_size, + "img": f.suffix.lower() in _IMAGE_MIME}) return out + +_IMAGE_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", + ".svg": "image/svg+xml"} + + +def _resolve_shared(username: str, name: str): + """Return a validated path inside this user's shared folder, or None.""" + if Path(name).name != name: # block path traversal + return None + p = (SHARED_DIR / username / name).resolve() + base = (SHARED_DIR / username).resolve() + if not str(p).startswith(str(base)): + return None + return p if p.is_file() else None + + +@app.get("/files/raw/{name}") +async def files_raw(request: Request, name: str) -> Response: + """Inline the file (images render as a preview).""" + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + p = _resolve_shared(user.username, name) + if p is None: + return Response(b"Not found", status_code=404) + media = _IMAGE_MIME.get(p.suffix.lower(), "application/octet-stream") + return Response(content=p.read_bytes(), media_type=media) + + +@app.get("/files/dl/{name}") +async def files_dl(request: Request, name: str) -> Response: + """Download (forced attachment) a file so it can be saved/emailed.""" + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + p = _resolve_shared(user.username, name) + if p is None: + return Response(b"Not found", status_code=404) + media = _IMAGE_MIME.get(p.suffix.lower(), "application/octet-stream") + return Response(content=p.read_bytes(), media_type=media, + headers={"Content-Disposition": f'attachment; filename="{p.name}"'}) + + @app.get("/files", response_class=HTMLResponse) async def files_page(request: Request) -> HTMLResponse: user = await _current_user(request) diff --git a/portal/templates/files.html b/portal/templates/files.html index f8d635e..b47ca72 100644 --- a/portal/templates/files.html +++ b/portal/templates/files.html @@ -27,7 +27,17 @@ {% if files %}