File size: 4,759 Bytes
fba6023
 
 
 
 
 
c8471e4
fba6023
 
3493993
fba6023
3493993
fba6023
c8471e4
fba6023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64412b1
 
 
 
 
 
 
 
 
 
 
 
 
 
fba6023
 
 
 
 
 
8939f6f
 
 
 
 
 
 
 
 
 
 
 
fba6023
 
 
 
3493993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fba6023
8939f6f
 
 
c8471e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8939f6f
 
c8471e4
 
8939f6f
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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)
    )