"""Question-image upload/serve pair (phase 123, TODO L6 — attach an image to a question). A user attaches ONE image to a chat question (LOCKED A5): the bytes are stored server-side under ``chat_image_dir/.`` — NOT base64 in saved/shared chats. ``POST /api/chat-images`` answers ``{"path": "/api/chat-images/."}`` and that path is what ``ChatRequest.image`` / ``ChatMessage.image`` carry (a stored PATH, never a data URL — the upload endpoint owns the size/mime enforcement). ``GET /api/chat-images/{filename}`` serves the bytes back so the user bubble, the refreshed page, and the shared chat can render the attachment. A question image is NEVER indexed as a document (no importer call) — it is turn-local storage, not a source. Auth posture: the upload is user-gated exactly like the chat turn it feeds (``require_user`` — the question itself is user-gated, and an anonymous 10 MiB disk-fill would be a DoS); the serve route is PUBLIC like saved-chat content (phase 55 A1 — a saved chat's id is already its credential, and the image is part of that content; the filename is an unguessable ``uuid4().hex`` — no enumeration value). The extension is the source of truth (the Content-Type header is a hint — the archive-uploader precedent): it must be in the phase-122 image set (``settings.image_extension_set`` — the same frozenset the image-document walk uses), lowercased; total bytes are streamed with a ``chat_image_max_mb`` cap (413, fixed detail naming the cap — never echoing the filename). """ from __future__ import annotations import logging import re import uuid from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse from app.config import get_settings from app.core.auth import require_user from app.rag.summarizer import IMAGE_FALLBACK_MIME, IMAGE_MIMES logger = logging.getLogger(__name__) router = APIRouter(tags=["chat-images"]) #: Stream receive chunk (the git-sources upload's 1 MiB pattern). _STREAM_CHUNK = 1 << 20 #: Stored question-image filename guard: ``.`` — 32 #: hex chars + one of the six image extensions (the ``ChatRequest.image`` #: path pattern's filename part, kept in lockstep with it). Anything #: else 404s — no path traversal by construction (the route parameter #: cannot carry a ``/`` and the regex rejects everything but the #: upload endpoint's own naming). _CHAT_IMAGE_FILENAME_RE = re.compile(r"^[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)$") @router.post("/chat-images") async def upload_chat_image( file: UploadFile = File(...), # noqa: B008 _user: None = Depends(require_user), # noqa: B008 ) -> dict[str, str]: """Store one question image (phase 123, task 01; LOCKED A5). Gates, in order: 1. **extension** — the file name's extension (lowercased) must be in the phase-122 image set (``settings.image_extension_set``); the Content-Type header is a hint, never a source of truth (the archive-uploader precedent). A missing/unknown extension is a 422 naming the accepted set — a fixed detail, no filename echo. 2. **size** — the bytes are streamed (1 MiB chunks) with the ``chat_image_max_mb`` cap; over-cap is a 413 naming the cap (fixed detail — never echoing the filename), the temp file is removed, and nothing is stored. The file lands as ``.`` in ``chat_image_dir`` (created on demand; a dotfile temp is renamed into place, so a failed/partial receive never leaves a servable-looking file). The response is the served path — ``{"path": "/api/chat-images/."}`` — the value ``ChatRequest.image`` accepts (never a data URL, never the on-disk location). """ settings = get_settings() filename = file.filename or "" ext = Path(filename).suffix.lower() if ext not in settings.image_extension_set: accepted = ", ".join(sorted(settings.image_extension_set)) raise HTTPException( status_code=422, detail=f"only {accepted} images are accepted" ) root = Path(settings.chat_image_dir).expanduser() root.mkdir(parents=True, exist_ok=True) name = f"{uuid.uuid4().hex}{ext}" temp = root / f".{name}.upload" max_bytes = settings.chat_image_max_mb * 1024 * 1024 total = 0 try: # Stream with the cap — the dotfile temp is hidden from any # listing of the store dir (the git-sources upload pattern). with open(temp, "wb") as out: while chunk := await file.read(_STREAM_CHUNK): total += len(chunk) if total > max_bytes: raise HTTPException( status_code=413, detail=( f"the image exceeds the " f"{settings.chat_image_max_mb} MB limit" ), ) out.write(chunk) temp.rename(root / name) except BaseException: # A failed receive (413, broken pipe, cancellation) leaves no # file behind — the final name was never created. temp.unlink(missing_ok=True) raise logger.info( "chat-image: uploaded name=%s bytes=%d", name, total ) return {"path": f"/api/chat-images/{name}"} @router.get("/chat-images/{filename}", response_class=FileResponse) def serve_chat_image(filename: str) -> FileResponse: """Serve one stored question image (phase 123, task 01). PUBLIC (no auth dependency) — like saved-chat content: the saved chat's id is already its credential (phase 55 A1), the image is part of that content, and the filename is an unguessable ``uuid4().hex`` (no enumeration value). Every non-servable case is a 404 with the same fixed detail — a filename that does not match ``.`` (the regex guard: no path traversal by construction, no 422 that would hint at accepted shapes) and a matching name whose file is missing (the stale-path edge — the file was deleted out-of-band). Servable files stream the exact bytes with the phase-122 ``IMAGE_MIMES`` ``Content-Type`` (one map, one truth with the describe call's data-URL mime; the six-extension guard makes the fallback unreachable) and ``Cache-Control: private, max-age=3600`` (the phase-122 serve-route convention — the bytes are content-hashed uuids, bustable by re-upload). """ if _CHAT_IMAGE_FILENAME_RE.fullmatch(filename) is None: raise HTTPException(status_code=404, detail="chat image not found") path = Path(get_settings().chat_image_dir).expanduser() / filename if not path.is_file(): raise HTTPException(status_code=404, detail="chat image not found") media_type = IMAGE_MIMES.get(path.suffix.lower(), IMAGE_FALLBACK_MIME) return FileResponse( path, media_type=media_type, headers={"Cache-Control": "private, max-age=3600"}, )