from __future__ import annotations from collections.abc import AsyncIterator from pathlib import Path import aiofiles from fastapi import APIRouter, Query, Request from fastapi.responses import StreamingResponse from app.core.exceptions import NotFoundError from app.core.response import SuccessResponse from app.security.assets import CanonicalAssetNotFoundError from app.services.media_service import MediaProcessor, Operation from app.services.media_url_signer import MediaURLSigner router = APIRouter(tags=["media"]) def get_processor(request: Request) -> MediaProcessor: return request.app.state.container.processor async def execute_operation(request: Request, name: str, operation: Operation) -> SuccessResponse: request.state.operation = name processor = get_processor(request) resolved = await processor.resolver.resolve(request) return await processor.run(resolved, name, operation) @router.post("/v1/media/inspect", response_model=SuccessResponse, name="inspect_media") async def inspect_media(request: Request) -> SuccessResponse: """Inspect uploaded or remotely referenced media without transforming it. Accepts the same multipart, JSON URL/Base64, n8n binary, and raw-body inputs as processing endpoints. The shared resolver and FFprobe validation keep this endpoint's behavior consistent with the processing pipeline. """ request.state.operation = "media.inspect" processor = get_processor(request) resolved = await processor.resolver.resolve(request) return await processor.probe(resolved) async def stream_file(path: Path) -> AsyncIterator[bytes]: async with aiofiles.open(path, "rb") as media: while chunk := await media.read(1024 * 1024): yield chunk def _download_headers(path: Path, request_id: str) -> dict[str, str]: return { "Content-Disposition": f'attachment; filename="{path.name}"', "Content-Length": str(path.stat().st_size), "X-Request-ID": request_id, "X-Content-Type-Options": "nosniff", # Media links are capabilities or workspace-scoped resources. Avoid # intermediary/browser cache retention beyond their authorization. "Cache-Control": "private, no-store", } @router.get("/v1/media/{request_id}/{filename}", name="download_media") async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse: request.state.operation = "media.download" container = request.app.state.container # The filesystem locator is not an authorization token. In authenticated # deployments an output must have been issued by the pipeline to the # caller's authoritative workspace before it can be downloaded. if container.settings.auth_enabled: context = getattr(request.state, "auth", None) if context is None or not context.workspace_id: raise NotFoundError("Output file not found") try: asset = await container.assets.get_owned( workspace_id=context.workspace_id, request_id=request_id, filename=filename, ) path = container.cleanup.resolve_download(request_id, asset.filename) await container.assets.verify_file(asset, path) except CanonicalAssetNotFoundError as exc: raise NotFoundError("Output file not found") from exc else: path = container.cleanup.resolve_download(request_id, filename) media_type = container.validator.infer_mime(path) return StreamingResponse( stream_file(path), media_type=media_type, headers=_download_headers(path, request_id) ) @router.get("/v1/media/signed/{request_id}/{filename}", name="download_signed_media") async def download_signed_media( request: Request, request_id: str, filename: str, expires: int = Query(..., ge=1), signature: str = Query(..., min_length=16), ) -> StreamingResponse: """Serve a published file using a time-limited HMAC URL. This endpoint intentionally does not require an Authorization header. Possession of a valid, unexpired signature is the capability. The signature is bound to the exact request ID and filename. """ request.state.operation = "media.signed_download" container = request.app.state.container signer = MediaURLSigner(container.settings) if not signer.verify(request_id, filename, expires, signature): raise NotFoundError("Output file not found") path = container.cleanup.resolve_download(request_id, filename) media_type = container.validator.infer_mime(path) return StreamingResponse( stream_file(path), media_type=media_type, headers=_download_headers(path, request_id) )