patristic-be / src /api /routers /uploads.py
Mario33333's picture
deploy: reader-access security hardening (feature/audiobook@06a5ed8)
7c2d250 verified
Raw
History Blame Contribute Delete
5.16 kB
"""``POST /uploads`` — multipart-PDF upload for the Add Book wizard.
The wizard's flow is:
1. User picks a PDF in the browser. The FE POSTs the file as multipart
form-data to this endpoint and gets back an :class:`UploadResponse`
with a stable ``uploadId``.
2. The FE hands that id to ``POST /books/probe`` (cheap probe, may cost
~$0.001 of Gemini metadata).
3. The FE finally hands the id to ``POST /books`` which moves the bytes
into the storage backend at ``data/raw/{bookId}.pdf``.
We deliberately keep the temp area on the local filesystem
(``data/uploads/{uploadId}.pdf``) rather than handing the bytes
straight to the storage backend — R2 round-trips would slow the wizard
to a crawl and the bytes don't deserve a permanent home until the user
commits via ``POST /books``.
Cleanup of the temp dir is best-effort: the create endpoint deletes the
``data/uploads/{uploadId}.pdf`` file after a successful move; a stale
file from a wizard the user abandoned mid-flow gets reaped by a future
TTL sweep (out of scope for Stage 6 — current footprint is tiny).
"""
from __future__ import annotations
import hashlib
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, UploadFile
from src.api.deps import require_admin
from src.api.dto.addbook import UploadResponse
from src.api.errors import BadRequest
from src.config import load_config
from src.lib.auth.models import User
router = APIRouter(prefix="/uploads", tags=["uploads"])
# Read chunks in 1 MiB blocks while hashing. Big enough to amortise
# Python-loop overhead, small enough to keep peak RSS flat on a slow
# disk + a 500 MiB PDF (the user has at least one).
_HASH_CHUNK_BYTES = 1024 * 1024
# Hard limit so a misclick on a 4 GiB ISO doesn't fill the temp dir.
# Aligned with the largest PDF in the inventory (~500 MiB) plus headroom.
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GiB
def _uploads_dir() -> Path:
"""Resolve ``data/uploads/`` from config; create it on first use.
Reuses ``cfg.paths.data_dir`` so the same .env / config.yaml that
targets a non-default ``data/`` folder works here. The directory
is created lazily so a fresh checkout doesn't need ``mkdir`` in
its setup script.
"""
cfg = load_config()
out = cfg.paths.data_dir / "uploads"
out.mkdir(parents=True, exist_ok=True)
return out
@router.post("", response_model=UploadResponse, status_code=201)
async def post_upload(
file: UploadFile = File(...),
_: User = Depends(require_admin),
) -> UploadResponse:
"""Accept a multipart file upload, return a stable upload id.
The id is a fresh uuid4 hex string — opaque, not user-meaningful.
The bytes land at ``data/uploads/{uploadId}.pdf`` regardless of the
client-provided filename. ``sha256`` lets the FE cache probe results
by content (so re-uploading the same PDF after a wizard abort
doesn't re-bill the Gemini probe call); ``sizeBytes`` is useful for
the wizard's "Confirm" step copy ("You're about to upload a 12.4 MB
PDF…").
Admin-only: write operations on the inventory require the admin
role (CONTRACT.md §3, BACKEND_BUILD.md §7).
"""
# Eager content-type sanity. We accept ``application/pdf`` (the
# canonical) and a couple of common variants browsers emit.
ct = (file.content_type or "").lower()
if ct and not (ct == "application/pdf" or ct.endswith("/pdf") or ct == "application/octet-stream"):
raise BadRequest(
f"Unsupported content-type {file.content_type!r}; expected application/pdf."
)
upload_id = uuid.uuid4().hex
dest = _uploads_dir() / f"{upload_id}.pdf"
sha = hashlib.sha256()
written = 0
# Stream the upload to disk + hash in one pass. UploadFile.read(N)
# returns awaited bytes from Starlette's SpooledTemporaryFile, so we
# never materialise the whole upload in memory.
try:
with dest.open("wb") as fh:
while True:
chunk = await file.read(_HASH_CHUNK_BYTES)
if not chunk:
break
written += len(chunk)
if written > _MAX_UPLOAD_BYTES:
# Best-effort cleanup, then bail.
fh.close()
try:
dest.unlink()
except FileNotFoundError:
pass
raise BadRequest(
f"Upload exceeds the {_MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit."
)
sha.update(chunk)
fh.write(chunk)
finally:
await file.close()
if written == 0:
# Empty multipart part. Drop the empty file and surface the error.
try:
dest.unlink()
except FileNotFoundError:
pass
raise BadRequest("Empty upload (no bytes received).")
return UploadResponse(
upload_id=upload_id,
sha256=sha.hexdigest(),
size_bytes=written,
)