**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**
- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
178 lines
8.0 KiB
Python
178 lines
8.0 KiB
Python
"""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/<uuid4().hex>.<ext>`` — NOT
|
|
base64 in saved/shared chats. ``POST /api/chat-images`` answers
|
|
``{"path": "/api/chat-images/<uuid>.<ext>"}`` 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) minus the vector members
|
|
(:data:`_VECTOR_IMAGE_EXTENSIONS` — a question image is RASTER-only,
|
|
phase 181), 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: ``<uuid4().hex>.<ext>`` — 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)$")
|
|
|
|
#: The VECTOR members of ``settings.image_extension_set`` — excluded
|
|
#: from the question-image accept set (phase 181: the vector family
|
|
#: belongs to INDEXING, not to question attachments). Two reasons, both
|
|
#: hard: an OpenAI-compatible vision endpoint rejects
|
|
#: ``image/svg+xml`` (the phase-181 LOCKED A1 reason — the vector file
|
|
#: never reaches the describer at all), and
|
|
#: :data:`_CHAT_IMAGE_FILENAME_RE` above deliberately refuses to SERVE a
|
|
#: ``.svg``, so an accepted upload would be stored, unanswerable and
|
|
#: unservable (a broken figure in the user's own bubble). Subtracting
|
|
#: instead of touching the serve regex keeps question attachments
|
|
#: raster-only by design while ``svg`` rides the image set for the
|
|
#: document walk.
|
|
_VECTOR_IMAGE_EXTENSIONS: frozenset[str] = frozenset({".svg"})
|
|
|
|
|
|
@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``)
|
|
MINUS the vector members (:data:`_VECTOR_IMAGE_EXTENSIONS` —
|
|
question images are RASTER-only, phase 181); the Content-Type
|
|
header is a hint, never a source of truth (the archive-uploader
|
|
precedent). A missing/unknown/vector 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 ``<uuid4().hex>.<ext>`` 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/<uuid>.<ext>"}`` — 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()
|
|
accepted_extensions = settings.image_extension_set - _VECTOR_IMAGE_EXTENSIONS
|
|
if ext not in accepted_extensions:
|
|
accepted = ", ".join(sorted(accepted_extensions))
|
|
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 ``<uuid-hex>.<ext>`` (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"},
|
|
)
|