phase: 148_pdf_pages_in_sources
**Phase 148 — final verification pass: complete, 100% green.**
- **Verified (tasks 01–04 already implemented):** page render/persist at index (`_persist_page_copies`, fail-soft, stale-replace, qualifier-loss, prune hook via `_delete_page_copies`); public `GET /api/documents/{id}/pages/{n}` (uuid4-credential, 404-all-non-servable, `image/png` + cache header); `DocContent.page_urls` additive-omitted; shared `renderDocument` strip (lazy imgs, "Page N" captions, text + Summary suppressed, onerror note, createElement-only); migration 0026; `pdf_page_dir` setting; `.env.example` entry
- **Completion criteria:** (1) pictorial + fallback classes get `p{N}.png` + `page_count`, text-heavy gets neither — pinned, pass; (2) unchanged re-sync renders zero (fast path) — pass; (3) changed re-index replaces copies, qualifier-loss/prune delete — pass; (4) route 404 matrix + payload key absent-never-null — pass; (5) modal AND /document.html strip with suppression, text-heavy byte-identical — E2E pass; (6) see below; (7) no completed-phase drift — full suite green
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3461 passed, 0 failed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings
- **Documentation:** README had **1 pre-existing inconsistency** — the Production Deployment section's standalone `podman run` example (and compose note) omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the fail-loud boot gate the README's own Admin section documents — the container would refuse to start); fixed with the two `-e` flags + a note for the compose path. All else verified consistent: 70/70 `.env.example` vars documented, all defaults match `config.py`, pages table matches routes, Containerfile/esbuild/uid-10001/healthcheck/entrypoint claims accurate, QA commands match pytest config. **AGENTS.md: verified consistent — no changes needed**
- **Notable:** repo uses `Containerfile` (Podman) — README's `podman build` example is valid as-is; no live infrastructure touched
- **Next pending phase:** `149_sync_progress_box`
This commit is contained in:
@@ -40,6 +40,20 @@ traversal/UUID-guessing has no filesystem surface to hit). The
|
||||
document's TEXT (the content endpoint) stays user-gated — only the
|
||||
bytes are public.
|
||||
|
||||
GET /api/documents/{doc_id}/pages/{n} — the phase-148 (task 02) PDF
|
||||
page BYTES route: ONE rendered page PNG (``<doc-id>-p{N}.png`` in the
|
||||
importer's ``pdf_page_dir`` copy — task 01) of a page-QUALIFYING
|
||||
document (the phase-133 fallback class or the phase-140 pictorial
|
||||
class). PUBLIC — the image route's phase-124 A4 trust model verbatim
|
||||
(a shared chat can cite a page figure, so the bytes serve
|
||||
ANONYMOUSLY; the unguessable ``uuid4`` document id IS the credential —
|
||||
the document's TEXT stays user-gated). ``image/png`` +
|
||||
``Cache-Control: private, max-age=3600`` (the image route's header);
|
||||
404 ``document not found`` for EVERY non-servable case (an
|
||||
unparseable id, an unknown doc, ``page_count`` NULL, ``n`` outside
|
||||
``1..page_count``, or a copy lost on disk). The path is built from the
|
||||
ROW's id + ``n`` only — no request value reaches a filesystem path.
|
||||
|
||||
PATCH /api/folders/summary — the admin folder-description editor
|
||||
(phase 97, task 03): update / create / clear a stored
|
||||
``folder_summaries`` row, marking every non-empty save
|
||||
@@ -228,6 +242,18 @@ def get_document_content(
|
||||
),
|
||||
origin_url=origin[0] if origin else None,
|
||||
origin_branch=origin[1] if origin else None,
|
||||
# Phase 148 (task 02): the rendered-page BYTES routes for a
|
||||
# page-qualifying PDF — the exact 1-based list over
|
||||
# ``1..page_count`` (the viewer's strip source, task 03; a
|
||||
# per-page render failure leaves a GAP the route 404s). ABSENT
|
||||
# (never null — the ``image_url`` omission rule) for every
|
||||
# other document, so its payload is byte-identical to
|
||||
# pre-phase.
|
||||
page_urls=(
|
||||
[f"/api/documents/{doc.id}/pages/{i}" for i in range(1, doc.page_count + 1)]
|
||||
if doc.page_count is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,6 +316,69 @@ def get_document_image(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/{doc_id}/pages/{n}", response_class=FileResponse)
|
||||
def get_document_page(
|
||||
doc_id: str,
|
||||
n: int,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> FileResponse:
|
||||
"""The phase-148 (task 02) PDF page BYTES route — ONE rendered page
|
||||
PNG (``<doc-id>-p{N}.png`` in the importer's ``pdf_page_dir`` copy,
|
||||
task 01) of a page-QUALIFYING document.
|
||||
|
||||
PUBLIC (the phase-124 A4 trust model verbatim — the image route's
|
||||
precedent): a shared chat can cite a page FIGURE (the viewer's
|
||||
strip, task 03), so the bytes serve ANONYMOUSLY. The document id
|
||||
is an unguessable server-minted ``uuid4`` — IT IS the credential,
|
||||
with no enumeration value — exactly like the image route and the
|
||||
saved-chat id (phase 55). The document's TEXT stays user-gated (the
|
||||
content endpoint): only the page bytes are public.
|
||||
|
||||
404 ``document not found`` (the router's unknown-document shape) in
|
||||
EVERY non-servable case — an unparseable ``doc_id`` (the
|
||||
``uuid.UUID`` try/except maps here, not to a 422 — the image
|
||||
route's mapping: a guessed id is an unknown document), an unknown
|
||||
doc, a doc whose ``page_count`` is NULL (a non-qualifying doc — the
|
||||
route knows nothing of its pages), ``n`` outside ``1..page_count``
|
||||
(out of range — a GAP, a page whose render failed, hits the
|
||||
missing-file arm below), or a file on disk that is missing
|
||||
(defensive — the row exists, the bytes don't; the viewer's onerror
|
||||
note covers it).
|
||||
|
||||
``n`` is an ``int`` path parameter (FastAPI parses it — a non-int
|
||||
422s at the router), and the path is built ONLY from the row's id
|
||||
+ ``n`` (``pdf_page_dir/<doc.id>-p{n}.png``) — no request value
|
||||
reaches a filesystem path (no traversal surface, the image route's
|
||||
property).
|
||||
|
||||
Servable pages stream the exact stored bytes as ``image/png``
|
||||
(``render_pdf_pages``'s ``tobytes("png")`` — a page is always a
|
||||
PNG) with ``Cache-Control: private, max-age=3600`` (the image
|
||||
route's header — the bytes are content-addressed by the document
|
||||
id + page number, bustable by re-index).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="document not found") from None
|
||||
doc = db.scalar(select(Document).where(Document.id == uid))
|
||||
if doc is None or doc.page_count is None:
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
if not 1 <= n <= doc.page_count:
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
page_file = Path(get_settings().pdf_page_dir).expanduser() / f"{doc.id}-p{n}.png"
|
||||
if not page_file.is_file():
|
||||
# Defensive: the row qualifies but the copy was lost (the owner
|
||||
# cleaned the page dir, the disk was wiped) — the viewer's
|
||||
# onerror note renders "Page N unavailable".
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
return FileResponse(
|
||||
page_file,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "private, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/documents/summary", response_model=SummaryResult)
|
||||
async def update_document_summary(
|
||||
payload: SummaryUpdate,
|
||||
|
||||
Reference in New Issue
Block a user