MediaRouter / app /api /media.py
basyx's picture
feat: add unified media inspection endpoint
64412b1 verified
Raw History Blame Contribute Delete
4.76 kB
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)
)