- api/admin/users.py: removed module docstring + 7 WHAT function docstrings - api/admin/quotas.py: removed module docstring + 2 WHAT function docstrings - api/admin/ai.py: removed module docstring + 3 WHAT inline comments; security invariant docstrings preserved - api/admin/shared.py: unchanged (all comments are WHY — constraint notes) - api/documents/upload.py: removed 4 WHAT inline comments; T-03-05/T-03-06 WHY notes preserved - api/documents/content.py: removed WHAT function docstring + WHAT inline comment - api/documents/crud.py: removed 7 WHAT inline comments; D-16 + security constraint docstrings preserved - api/auth/shared.py: removed module docstring + 1 WHAT inline comment - api/auth/tokens.py: removed module docstring + 8 WHAT function docstrings/comments; family-revocation + SEC-02 WHY notes preserved - api/auth/totp.py: removed module docstring + 2 WHAT function docstrings + 2 WHAT inline comments - api/auth/password.py: removed module docstring + 2 WHAT function docstrings + 3 WHAT inline comments - All NO-prefix anchor comments and security-invariant WHY comments preserved - pytest: 413 passed, 1 failed (pre-existing ModuleNotFoundError unrelated to purge)
130 lines
4.7 KiB
Python
130 lines
4.7 KiB
Python
"""Document content streaming endpoint.
|
|
|
|
Endpoints:
|
|
GET /{doc_id}/content — stream document bytes from any backend (MinIO or cloud)
|
|
|
|
Sub-router carries NO prefix — prefix="/api/documents" lives in __init__.py (D-04).
|
|
|
|
Security:
|
|
T-04-05-01: uses get_regular_user — admin role → 403 (critical security invariant).
|
|
T-04-05-02: bytes fetched via get_object() ONLY — presigned_get_url() never called.
|
|
T-04-05-03: Range header validated via _parse_range(); invalid range → 416.
|
|
T-04-05-04: access gated on ownership OR active Share.recipient_id.
|
|
DOC-04 / SEC-04: ownership assertion before serving bytes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import urllib.parse
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.models import Document, Share, User
|
|
from deps.auth import get_regular_user
|
|
from deps.db import get_db
|
|
from services.rate_limiting import account_limiter
|
|
from storage.exceptions import CloudConnectionError
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Range header parsing helper ───────────────────────────────────────────────
|
|
|
|
def _parse_range(range_header: str, file_size: int) -> tuple:
|
|
"""Parse a 'bytes=X-Y' Range header and return (start, end).
|
|
|
|
Returns (start, end) where both are inclusive byte offsets.
|
|
Raises HTTP 416 on any invalid or out-of-bounds range.
|
|
|
|
T-04-05-03: validates start <= end, start >= 0, end < file_size.
|
|
"""
|
|
try:
|
|
h = range_header.replace("bytes=", "").split("-")
|
|
start = int(h[0]) if h[0] != "" else 0
|
|
end = int(h[1]) if h[1] != "" else file_size - 1
|
|
except (ValueError, IndexError):
|
|
raise HTTPException(status.HTTP_416_RANGE_NOT_SATISFIABLE)
|
|
if start > end or start < 0 or end >= file_size:
|
|
raise HTTPException(status.HTTP_416_RANGE_NOT_SATISFIABLE)
|
|
return start, end
|
|
|
|
|
|
# ── GET /api/documents/{doc_id}/content ──────────────────────────────────────
|
|
|
|
@router.get("/{doc_id}/content")
|
|
@account_limiter.limit("100/minute")
|
|
async def stream_document_content(
|
|
doc_id: str,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
):
|
|
request.state.current_user = current_user
|
|
try:
|
|
uid = uuid.UUID(doc_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
|
|
doc = await session.get(Document, uid)
|
|
if doc is None:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
|
|
# Access control: owner OR share recipient (T-04-05-04)
|
|
if doc.user_id != current_user.id:
|
|
result = await session.execute(
|
|
select(Share).where(
|
|
Share.document_id == doc.id,
|
|
Share.recipient_id == current_user.id,
|
|
)
|
|
)
|
|
share = result.scalar_one_or_none()
|
|
if share is None:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
|
|
try:
|
|
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
|
storage_backend = await _doc_pkg.get_storage_backend_for_document(doc, current_user, session)
|
|
file_bytes = await storage_backend.get_object(doc.object_key)
|
|
except CloudConnectionError as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
|
|
) from exc
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="Cloud backend unreachable. Please try again or reconnect in Settings.",
|
|
) from exc
|
|
file_size = len(file_bytes)
|
|
|
|
safe_name = urllib.parse.quote(doc.filename, safe='')
|
|
headers = {
|
|
"content-type": doc.content_type,
|
|
"content-disposition": f"inline; filename*=UTF-8''{safe_name}",
|
|
"accept-ranges": "bytes",
|
|
"content-length": str(file_size),
|
|
}
|
|
|
|
range_header = request.headers.get("range")
|
|
if range_header:
|
|
start, end = _parse_range(range_header, file_size)
|
|
chunk = file_bytes[start : end + 1]
|
|
headers["content-range"] = f"bytes {start}-{end}/{file_size}"
|
|
headers["content-length"] = str(len(chunk))
|
|
return StreamingResponse(
|
|
iter([chunk]),
|
|
status_code=206,
|
|
headers=headers,
|
|
)
|
|
|
|
return StreamingResponse(
|
|
iter([file_bytes]),
|
|
status_code=200,
|
|
headers=headers,
|
|
)
|