"""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), ): """Stream document bytes directly from MinIO (DOC-02). 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. Returns 200 (or 206 for Range requests) with: Content-Type: doc.content_type Content-Disposition: inline; filename="" Accept-Ranges: bytes Content-Length: """ 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") # Fetch bytes from the correct backend — get_storage_backend_for_document handles # all backends (MinIO, Google Drive, OneDrive, Nextcloud, WebDAV) transparently # (D-15, T-04-05-02). NEVER via presigned URL for cloud backends (D-14). 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, )