Spaces:
Running
Running
| """``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 | |
| 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, | |
| ) | |