"""Stored files: upload (multipart), metadata, and the same-origin blob route. The upload only needs a session; the attach endpoints (packet pages, documents) check the policy. The form is read after the session check, so a request without a session never gets its body spooled. """ from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import FileResponse from psycopg.types.json import Jsonb from starlette.concurrency import run_in_threadpool from starlette.datastructures import UploadFile from ..domain import assets from .deps import Db, Principal, current_principal, get_db, settings_of from .errors import ApiError router = APIRouter(prefix="/api/assets", tags=["assets"]) blob_router = APIRouter(tags=["assets"]) # Multipart overhead allowed on top of the file limit before the body is refused unread. FORM_SLACK = 64 * 1024 @router.post("", status_code=201) async def upload( request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: s = settings_of(request) length = request.headers.get("content-length") if length is not None and length.isdigit() and int(length) > s.upload_max_bytes + FORM_SLACK: raise ApiError(413, "too_large", f"The file is over the {s.upload_max_bytes // (1024 * 1024)} MB limit.") form = await request.form(max_files=1, max_fields=8) try: file = form.get("file") if not isinstance(file, UploadFile): raise ApiError(422, "invalid_request", "Send the file in the 'file' field.") packet_id: int | None = None raw_pid = form.get("packet_id") if isinstance(raw_pid, str) and raw_pid.strip(): if not raw_pid.strip().isdigit(): raise ApiError(422, "invalid_request", "packet_id must be a number.") packet_id = int(raw_pid.strip()) exists = await run_in_threadpool( lambda: d.conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone() ) if exists is None: raise ApiError(404, "not_found", "No such packet.") purpose = form.get("purpose") ref = {"purpose": str(purpose)[:40]} if isinstance(purpose, str) and purpose.strip() else None upload_file = file return await run_in_threadpool( lambda: assets.store_upload( d.conn, s, upload_file.file, original_name=upload_file.filename, user=p.user_id, packet_id=packet_id, source_ref=None if ref is None else Jsonb(ref), actor=p, ) ) finally: await form.close() @router.get("/{asset_id}") def metadata( asset_id: str, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: row = assets.get(d.conn, asset_id) if row is None: raise ApiError(404, "not_found", "No such file.") return assets.public(row) @blob_router.get("/_blob/{asset_id}") def blob( asset_id: str, request: Request, download: int = Query(0, ge=0, le=1), p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> Response: row = assets.get(d.conn, asset_id) if row is None: raise ApiError(404, "not_found", "No such file.") path = assets.open_blob(settings_of(request), row) etag = '"' + bytes(row["sha256"]).hex() + '"' headers = { "Content-Disposition": f'{"attachment" if download else "inline"}; filename="{assets.download_name(row)}"', "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=3600", "ETag": etag, } if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers=headers) return FileResponse(path, media_type=str(row["mime"]), headers=headers)