GET /api/documents/{id}/content streams document bytes from MinIO through FastAPI
Range header requests return 206 with correct Content-Range header
Admin cannot access the streaming proxy — get_regular_user dep returns 403
No presigned URL is generated or exposed in the proxy response
Share recipients can access document content via the proxy
PATCH /api/auth/me/preferences stores and returns pdf_open_mode preference
path
provides
backend/api/documents.py
GET /api/documents/{id}/content streaming proxy with Range header support
path
provides
backend/api/auth.py
PATCH /api/auth/me/preferences endpoint for pdf_open_mode
from
to
via
pattern
backend/api/documents.py
backend/storage/minio_backend.py
get_storage_backend().get_object(doc.object_key) — bytes fetched directly, not via presigned URL
get_object
from
to
via
pattern
backend/api/documents.py
backend/db/models.py
Share model for _can_access_document() recipient check
Share.recipient_id
Add the PDF streaming proxy to the documents API (DOC-02) and the pdf_open_mode preferences
endpoint to the auth API (D-10, DOC-01). These can be implemented together since they both
modify existing API modules and neither depends on the other.
Purpose: Complete document content access — both the proxy stream and the user preference for
how to open PDFs.
Output: Streaming proxy endpoint in documents.py + preferences PATCH in auth.py.
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-RESEARCH.md
@backend/api/documents.py
@backend/api/auth.py
@backend/storage/minio_backend.py
@backend/db/models.py
Task 1: Add GET /api/documents/{id}/content streaming proxy to documents.py
backend/api/documents.py
backend/api/documents.py — read the entire file; identify current imports (Request, StreamingResponse if already present; func, Select, etc.); find the endpoint list to determine where to insert the new endpoint; confirm existing UUID parse pattern and get_regular_user usage
backend/storage/minio_backend.py — read to confirm the exact name and signature of get_object(); confirm it returns bytes (not a stream object)
backend/db/models.py — search for the Share class definition; confirm Share.document_id and Share.recipient_id column names
GET /api/documents/{id}/content:
- MUST use get_regular_user dep (admin role → 403 — Pitfall 3, CRITICAL)
- Parse doc_id as UUID → 404 "Document not found" on ValueError
- Load document via session.get(Document, uid) → 404 if None
- Access check: if doc.user_id == current_user.id → proceed; else query Share where Share.document_id == doc.id AND Share.recipient_id == current_user.id; if no share → 404 "Document not found"
- Fetch bytes: `file_bytes = await get_storage_backend().get_object(doc.object_key)` — this calls MinIO directly, NO presigned URL
- Build base headers: content-type = doc.content_type, content-disposition = `inline; filename="{doc.filename}"`, accept-ranges = "bytes", content-length = str(file_size)
- Range header handling: if request.headers.get("range") is truthy, call _parse_range() → on valid range: add content-range header, set content-length to chunk length, return StreamingResponse(iter([chunk]), status_code=206, headers=headers)
- No range: return StreamingResponse(iter([file_bytes]), status_code=200, headers=headers)
- _parse_range() is a module-level helper function, not an endpoint (defined above the endpoint)
DOC-01 note: GET /api/documents/{id} already returns extracted_text — verify this is still included in the existing response; add it to the response if absent (read the actual endpoint to check before modifying).
Modify backend/api/documents.py.
Add to imports if not already present: `from fastapi import Request` (alongside existing FastAPI imports), `from fastapi.responses import StreamingResponse`, `from fastapi import status`. Confirm `from db.models import Share` is in imports (added in plan 04-03 for the is_shared subquery — if absent, add it now).
Define _parse_range() helper function at module level (above the stream_document_content endpoint). Implement exactly as specified in the behavior block and RESEARCH.md Pattern 3: parse "bytes=X-Y" format, handle open-ended ranges (end = file_size - 1 when Y is empty), raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE) on invalid range.
Add the stream_document_content endpoint after the existing document endpoints. Implement the access check using an inline query for Share (do NOT call a separate helper function — the access check is in the handler body for clarity and to avoid test mocking complexity).
CRITICAL: endpoint decorator uses `get_regular_user` not `get_current_user`. This is verified by the security agent and by test_content_stream_admin_403.
CRITICAL: NEVER call presigned_get_url() or any presigned URL method inside this endpoint. Bytes must flow directly from MinIO through FastAPI.
cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_documents.py -x -v --no-header 2>&1 | tail -30
- `GET /api/documents/{id}/content` endpoint exists in documents.py (grep: `async def stream_document_content` or similar)
- Endpoint uses `get_regular_user` dep, NOT `get_current_user` (grep: `Depends(get_regular_user)` on the content endpoint; no `get_current_user` on content endpoint)
- _parse_range() helper exists at module level (grep: `def _parse_range`)
- StreamingResponse imported and used (grep: `StreamingResponse` in documents.py)
- No presigned URL call in the handler (grep: `presigned` absent from stream_document_content function body)
- Share access check present (grep: `Share.recipient_id` or `recipient_id` in documents.py)
- test_content_stream_admin_403 turns green (xpass) or remains xfail — not FAILED
- test_content_stream_no_presigned_url turns green or remains xfail — not FAILED
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
Streaming proxy delivers bytes via StreamingResponse; admin blocked; Range headers supported; no presigned URL exposure.
Task 2: Add PATCH /api/auth/me/preferences endpoint for pdf_open_mode
backend/api/auth.py
backend/api/auth.py — read the entire file; identify the /api/auth/me GET endpoint (existing); find the router prefix and how it is declared; confirm get_current_user (not get_regular_user) is used for /me endpoints (admin can read/update their own preferences); find the existing import block to add pydantic BaseModel if needed
backend/db/models.py — confirm users.pdf_open_mode column name and that it exists (added by migration 0004)
PATCH /api/auth/me/preferences:
- Auth: get_current_user (both regular users and admins can set their own PDF preference — D-10)
- Body: {pdf_open_mode: str} — validated to be one of ["in_app", "new_tab"]
- If pdf_open_mode not in allowed values → 422 (Pydantic validation handles this via Literal type or custom validator)
- Update current_user.pdf_open_mode = body.pdf_open_mode
- session.add(current_user); await session.commit()
- Return 200: {pdf_open_mode: current_user.pdf_open_mode}
GET /api/auth/me/preferences (optional but recommended for frontend to load initial value):
- Auth: get_current_user
- Return 200: {pdf_open_mode: current_user.pdf_open_mode}
- If pdf_open_mode column is missing from User model (migration not yet run), return {"pdf_open_mode": "in_app"} as default
Modify backend/api/auth.py.
Add a Pydantic request model: PreferencesUpdate with field `pdf_open_mode: Literal["in_app", "new_tab"]`. Import `Literal` from typing.
Add two new endpoints at the end of the router function list:
1. GET /api/auth/me/preferences — returns {pdf_open_mode: str}. Uses `get_current_user` dep. Reads current_user.pdf_open_mode. If AttributeError (column not yet in ORM map, e.g., test env without migration), return {"pdf_open_mode": "in_app"}.
2. PATCH /api/auth/me/preferences — accepts PreferencesUpdate body. Updates current_user.pdf_open_mode. Commits. Returns {pdf_open_mode: updated value}.
Both endpoints are placed after the existing /api/auth/me endpoint. No new router prefix needed — they use the same auth router.
Do NOT break any existing auth tests. Do NOT modify any existing endpoint.
cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.auth import router; print([r.path for r in router.routes])" 2>&1
- `/api/auth/me/preferences` appears in auth router routes (grep: `me/preferences` in auth.py)
- PATCH endpoint validates pdf_open_mode via Literal["in_app", "new_tab"] (grep: `Literal` and `pdf_open_mode` in auth.py)
- `python -c "from api.auth import router"` exits 0
- Existing auth tests still pass: `cd backend && python -m pytest tests/test_auth_api.py -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
- Full suite: `cd backend && python -m pytest -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
PATCH /api/auth/me/preferences stores pdf_open_mode; GET returns current value; existing auth tests unaffected.
<threat_model>
Trust Boundaries
Boundary
Description
Client → GET /api/documents/{id}/content
Untrusted doc_id; access checked via ownership or active share; bytes flow from MinIO through FastAPI only
Client → Range header
Untrusted byte ranges; _parse_range() validates start/end bounds against file_size; invalid range → 416