"""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 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) # The purpose field is inside the body, so the pre-check uses the largest kind limit. The store applies the # kind's own limit while it streams. pre = assets.max_upload_limit(s) length = request.headers.get("content-length") if length is not None and length.isdigit() and int(length) > pre + FORM_SLACK: raise assets.too_large(pre) 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.") raw_purpose = form.get("purpose") if raw_purpose is not None and not isinstance(raw_purpose, str): raise ApiError(422, "invalid_request", "purpose must be text.", extra={"field": "purpose"}) kind = assets.kind_of(raw_purpose) 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, actor=p, purpose=kind.name, ) ) 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": assets.disposition(row, bool(download)), "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)