Files page: thumbnails + view + download (email access to finished work)

This commit is contained in:
2026-08-26 15:30:15 +10:00
parent 1cc159ef80
commit 188e9a0dd6
2 changed files with 58 additions and 3 deletions

View File

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

View File

@@ -27,7 +27,17 @@
{% if files %}
<ul class="file-list">
{% for f in files %}
<li><span class="file-name" title="{{ f.name }}">{{ f.name }}</span><span class="faint">{{ (f.size/1024)|round(1) }} KB</span></li>
<li style="align-items:center;gap:12px">
{% if f.img %}
<img src="/files/raw/{{ f.name }}" alt="{{ f.name }}" style="width:56px;height:56px;object-fit:cover;border-radius:8px;border:1px solid var(--hairline,#e6e6e6)">
{% else %}
<span style="width:56px;height:56px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--hairline,#e6e6e6);border-radius:8px;color:#999;font-size:11px">file</span>
{% endif %}
<span class="file-name" title="{{ f.name }}" style="flex:1">{{ f.name }}</span>
<span class="faint">{{ (f.size/1024)|round(1) }} KB</span>
<a class="button button-utility" href="/files/raw/{{ f.name }}" target="_blank" title="View">View</a>
<a class="button button-utility" href="/files/dl/{{ f.name }}" title="Download">Download ↓</a>
</li>
{% endfor %}
</ul>
<p class="faint margin-top" style="font: var(--type-caption)">