"""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 from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from db.models import User from api.documents.shared import get_accessible_document 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() 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. """ 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 @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 doc, _is_recipient = await get_accessible_document(session, doc_id, current_user.id) 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, )