Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ route is protected by default rather than by remembering a dependency):
| `POST /api/v1/anonymize/stream` | Same inputs, streams NDJSON `{"event":"progress"…}` lines then `{"event":"result"…}`. Inputs are parsed *before* streaming starts, so malformed requests still fail with normal HTTP errors. A client disconnect cancels the pipeline. |
| `POST /api/v1/anonymize/{request_id}/extend` | Keep a cached result for another TTL, never past the hard lifetime ceiling. 410 when it is already gone. Backs the review view's countdown + **Verlängern**. |
| `DELETE /api/v1/anonymize/{request_id}` | Forget one cached detection now. Always 204 (an unknown id must not be distinguishable). Called by the UI when a document is closed, reset, or the tab unloads. |
| `POST /api/v1/export/pdf` | Redacted-PDF export. The client **re-sends the original file** (nothing is stored); `request_id` + matching hash avoids re-running OCR/detection. |
| `POST /api/v1/export/pdf` | Redacted-PDF export. The client **re-sends the original file** (nothing is stored); `request_id` + matching hash avoids re-running OCR/detection. A refusal answers `{detail, code, forceable, items}`; `force_export=true` re-runs it and is honoured for `forceable` findings only. |
| `POST /api/v1/export/pdf/pages` | Renders pages as PNGs for the area-redaction editor, with embedded-image boxes as one-click suggestions. |
| `GET /api/v1/status` | Configured detectors + OCR engine, endpoint **hosts** and their locality, limits. Never returns paths, keys, or full URLs. |
| `GET /health/live`, `GET /health/ready` | Liveness/readiness. |
Expand Down Expand Up @@ -324,7 +324,7 @@ extensions `.txt/.docx/.pdf`. `Cache-Control: no-store` on content routes
| `utils/policy.py`, `utils/transformation.py` | Default policy, labels, pure transformations. |
| `utils/leakage.py` | Output validation + `compute_status`. |
| `utils/cache.py` | `request_cache` (TTL, bounded, in-memory). |
| `utils/pdf_export.py` | Native-PDF true redaction, rasterized fallback, scanned-PDF reconstruction, page rendering. **Fails closed**: an export that cannot be verified is refused. |
| `utils/pdf_export.py` | Native-PDF true redaction, rasterized fallback, scanned-PDF reconstruction, page rendering. **Fails closed**: an export that cannot be verified is refused. The one exception is a residual the anonymized *text* has too (`expected_text`), which is refused as `forceable` and exported only after the reviewer confirms it. |
| `utils/notices.py` | Stable codes + English text for every non-fatal message (the translation contract). |
| `utils/policy.py` | Default policy + the replacement placeholders of every output language. |
| `utils/auth.py` | Session + login-state tokens for the OIDC gate (HS256, PKCE helpers). No session store: the signed cookie *is* the session. |
Expand Down Expand Up @@ -757,7 +757,9 @@ npm run test:e2e # when you touched the API or the UI flow
exactly this; don't skip it because "it's only a label".
- **Silent degradation.** Never catch a `DetectorError` and continue with
fewer detectors, and never let a failed export produce an unverified PDF.
Failing loudly is the feature.
Failing loudly is the feature. `force_export` is not a hole in that: it
applies to one classified finding, the reviewer is told what stays visible,
and the PDF panel keeps saying so afterwards.
- **The cache is not persistence.** A 410 is normal; the frontend re-posts the
source text. Don't "fix" it by extending the TTL indefinitely or writing to
disk.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ documentation, and CI work are left out.
over the placeholders of a scanned document's rebuilt PDF, so it looks like a
native redacted PDF — see [Exporting](docs/user-guide/export.md#redacted-pdf).

### Changed

- A PDF export refused because a redacted text also occurs outside the redacted
passages — a name you kept, or an occurrence no detector found — now names
those passages and offers **Trotzdem exportieren** instead of failing, since
the anonymized text download shows them too. Every other verification failure
is still refused outright. See
[Exporting](docs/user-guide/export.md#when-the-check-finds-text-you-kept).

## [0.3.0] — 2026-08-20

### Added
Expand Down
53 changes: 49 additions & 4 deletions backend/src/routers/v1/endpoints/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@

Native PDFs are rasterized with exact char-box blackout; scanned PDFs are
rebuilt from the anonymized text at the OCR layout positions. Both paths fail
closed — an export that cannot be verified is refused."""
closed — an export that cannot be verified is refused. The single exception is
a finding the anonymized *text* download carries too (a passage the reviewer
kept that also occurs in redacted form): that one is refused with
`forceable: true` and exported only after the reviewer confirms it."""

import hashlib
import json

from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import TypeAdapter, ValidationError
from starlette.datastructures import UploadFile

Expand All @@ -30,6 +34,7 @@
from ....utils.ocr_profiles import OcrProfileError, resolve_vision_ocr_profile
from ....utils.pdf_export import (
ExportError,
export_detail,
rebuild_scanned_pdf,
redact_native_pdf,
render_pdf_pages,
Expand Down Expand Up @@ -95,6 +100,29 @@ def _parse_terms(raw) -> list[str] | None:
raise HTTPException(status_code=422, detail="Invalid terms payload.") from None


def _export_error_response(exc: ExportError) -> JSONResponse:
"""A refused export, as a body the UI can act on.

`detail` is looked up from the code rather than taken off the exception, so
no message an upstream library produced can reach the client; it stays the
English sentence every other error route returns, so a client that does not
know the code keeps showing something sensible. `code` and `forceable` are
what tells the review UI whether to offer "export anyway", and `items` are
the passages that would stay visible so the confirmation can name them —
up to a display cap, which is why `count` is reported separately. They are
document content and go to the client only — never to a log."""
return JSONResponse(
status_code=exc.status_code,
content={
"detail": export_detail(exc.code, exc.count),
"code": exc.code,
"forceable": exc.forceable,
"items": exc.items,
"count": exc.count,
},
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
)


@router.post("/export/pdf")
async def export_pdf(
request: Request,
Expand Down Expand Up @@ -132,6 +160,11 @@ async def export_pdf(
# page, so a reconstruction looks like the native export instead of reading
# its replacements out in words.
redaction_bars = _parse_bool(form.get("redaction_bars"))
# The reviewer confirmed a refused export whose only finding is that a
# redacted string also occurs outside the redacted passages — i.e. the PDF
# would show exactly what the anonymized text download already shows.
# Ignored by every other check; nothing else can be waved through.
force_export = _parse_bool(form.get("force_export"))
raw_profile = form.get("ocr_profile")
ocr_profile = (
raw_profile.strip() if isinstance(raw_profile, str) and raw_profile.strip() else None
Expand Down Expand Up @@ -161,7 +194,14 @@ async def export_pdf(

try:
if source_type == "pdf":
pdf_bytes = redact_native_pdf(data, result.entities, settings, areas=redact_areas)
pdf_bytes = redact_native_pdf(
data,
result.entities,
settings,
areas=redact_areas,
expected_text=result.anonymized_text,
force=force_export,
)
elif source_type == "pdf-ocr":
pdf_bytes = rebuild_scanned_pdf(
result.source_text,
Expand All @@ -170,20 +210,23 @@ async def export_pdf(
page_count,
areas=redact_areas,
bars=redaction_bars,
expected_text=result.anonymized_text,
force=force_export,
)
else:
raise HTTPException(
status_code=415, detail="Redacted-PDF export is available for PDF uploads only."
)
except ExportError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None
return _export_error_response(exc)

logger.info(
"export_pdf",
ref=log_reference(result.request_id),
source_type=source_type,
entities=len(result.entities),
areas=len(redact_areas),
forced=force_export,
size=len(pdf_bytes),
)
return Response(
Expand All @@ -210,7 +253,9 @@ async def export_pdf_pages(
try:
pages, truncated = render_pdf_pages(data)
except ExportError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None
raise HTTPException(
status_code=exc.status_code, detail=export_detail(exc.code, exc.count)
) from None
logger.info("export_pdf_pages", pages=len(pages), truncated=truncated)
return PdfPagesResponse.model_validate({"pages": pages, "truncated": truncated})

Expand Down
Loading