Spaces:
Running
Running
Add word-by-word motivational captions template
#3
by basyx - opened
- app/api/media.py +22 -16
- app/core/request_limits.py +88 -0
- app/operations/mix_audio.py +54 -0
- app/services/cleanup.py +32 -24
- app/services/downloader.py +87 -36
- app/services/media_service.py +73 -0
- app/services/media_url_signer.py +14 -3
- app/services/validator.py +19 -51
- app/templates/categories/motivation/templates.yaml +38 -0
- app/templates/categories/podcast/templates.yaml +28 -0
- app/templates/executor.py +46 -1
- docs/api-wide-output-validation.md +28 -0
- docs/artifact-download-hardening.md +17 -0
- docs/input-ingestion-security.md +16 -0
- docs/template-output-validation.md +38 -0
- docs/voiceover-music-ducking.md +53 -0
- docs/word-by-word-captions.md +48 -0
- tests/test_artifact_download_security.py +60 -0
- tests/test_downloader.py +54 -0
- tests/test_media_url_signer.py +34 -13
- tests/test_mix_audio.py +33 -0
- tests/test_output_validation.py +71 -0
- tests/test_templates.py +87 -4
app/api/media.py
CHANGED
|
@@ -33,6 +33,18 @@ async def stream_file(path: Path) -> AsyncIterator[bytes]:
|
|
| 33 |
yield chunk
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
@router.get("/v1/media/{request_id}/{filename}", name="download_media")
|
| 37 |
async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse:
|
| 38 |
request.state.operation = "media.download"
|
|
@@ -57,12 +69,9 @@ async def download_media(request: Request, request_id: str, filename: str) -> St
|
|
| 57 |
else:
|
| 58 |
path = container.cleanup.resolve_download(request_id, filename)
|
| 59 |
media_type = container.validator.infer_mime(path)
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
"X-Request-ID": request_id,
|
| 64 |
-
}
|
| 65 |
-
return StreamingResponse(stream_file(path), media_type=media_type, headers=headers)
|
| 66 |
|
| 67 |
|
| 68 |
@router.get("/v1/media/signed/{request_id}/{filename}", name="download_signed_media")
|
|
@@ -80,15 +89,12 @@ async def download_signed_media(
|
|
| 80 |
signature is bound to the exact request ID and filename.
|
| 81 |
"""
|
| 82 |
request.state.operation = "media.signed_download"
|
| 83 |
-
|
|
|
|
| 84 |
if not signer.verify(request_id, filename, expires, signature):
|
| 85 |
raise NotFoundError("Output file not found")
|
| 86 |
-
path =
|
| 87 |
-
media_type =
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
"X-Request-ID": request_id,
|
| 92 |
-
"Cache-Control": "private, max-age=60",
|
| 93 |
-
}
|
| 94 |
-
return StreamingResponse(stream_file(path), media_type=media_type, headers=headers)
|
|
|
|
| 33 |
yield chunk
|
| 34 |
|
| 35 |
|
| 36 |
+
def _download_headers(path: Path, request_id: str) -> dict[str, str]:
|
| 37 |
+
return {
|
| 38 |
+
"Content-Disposition": f'attachment; filename="{path.name}"',
|
| 39 |
+
"Content-Length": str(path.stat().st_size),
|
| 40 |
+
"X-Request-ID": request_id,
|
| 41 |
+
"X-Content-Type-Options": "nosniff",
|
| 42 |
+
# Media links are capabilities or workspace-scoped resources. Avoid
|
| 43 |
+
# intermediary/browser cache retention beyond their authorization.
|
| 44 |
+
"Cache-Control": "private, no-store",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
@router.get("/v1/media/{request_id}/{filename}", name="download_media")
|
| 49 |
async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse:
|
| 50 |
request.state.operation = "media.download"
|
|
|
|
| 69 |
else:
|
| 70 |
path = container.cleanup.resolve_download(request_id, filename)
|
| 71 |
media_type = container.validator.infer_mime(path)
|
| 72 |
+
return StreamingResponse(
|
| 73 |
+
stream_file(path), media_type=media_type, headers=_download_headers(path, request_id)
|
| 74 |
+
)
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
@router.get("/v1/media/signed/{request_id}/{filename}", name="download_signed_media")
|
|
|
|
| 89 |
signature is bound to the exact request ID and filename.
|
| 90 |
"""
|
| 91 |
request.state.operation = "media.signed_download"
|
| 92 |
+
container = request.app.state.container
|
| 93 |
+
signer = MediaURLSigner(container.settings)
|
| 94 |
if not signer.verify(request_id, filename, expires, signature):
|
| 95 |
raise NotFoundError("Output file not found")
|
| 96 |
+
path = container.cleanup.resolve_download(request_id, filename)
|
| 97 |
+
media_type = container.validator.infer_mime(path)
|
| 98 |
+
return StreamingResponse(
|
| 99 |
+
stream_file(path), media_type=media_type, headers=_download_headers(path, request_id)
|
| 100 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
app/core/request_limits.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class RequestBodyLimitMiddleware:
|
| 7 |
+
"""Enforce a hard byte ceiling on HTTP request bodies, including chunked bodies."""
|
| 8 |
+
|
| 9 |
+
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
|
| 10 |
+
self.app = app
|
| 11 |
+
self.max_bytes = max_bytes
|
| 12 |
+
|
| 13 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 14 |
+
if scope["type"] != "http" or scope["method"] in {"GET", "HEAD", "OPTIONS"}:
|
| 15 |
+
await self.app(scope, receive, send)
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
| 19 |
+
declared = headers.get(b"content-length")
|
| 20 |
+
if declared:
|
| 21 |
+
try:
|
| 22 |
+
if int(declared) > self.max_bytes:
|
| 23 |
+
await self._too_large(scope, receive, send)
|
| 24 |
+
return
|
| 25 |
+
except ValueError:
|
| 26 |
+
# Invalid Content-Length is left to the ASGI server/parser; the
|
| 27 |
+
# streamed byte counter below remains authoritative.
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
received = 0
|
| 31 |
+
exceeded = False
|
| 32 |
+
response_started = False
|
| 33 |
+
|
| 34 |
+
async def limited_receive() -> Message:
|
| 35 |
+
nonlocal received, exceeded
|
| 36 |
+
message = await receive()
|
| 37 |
+
if message["type"] == "http.request":
|
| 38 |
+
received += len(message.get("body", b""))
|
| 39 |
+
if received > self.max_bytes:
|
| 40 |
+
exceeded = True
|
| 41 |
+
# Do not pass an over-limit chunk into a multipart parser or
|
| 42 |
+
# request.body(); it could otherwise spool/read the full body.
|
| 43 |
+
return {"type": "http.disconnect"}
|
| 44 |
+
return message
|
| 45 |
+
|
| 46 |
+
async def guarded_send(message: Message) -> None:
|
| 47 |
+
nonlocal response_started
|
| 48 |
+
if message["type"] == "http.response.start":
|
| 49 |
+
if exceeded and not response_started:
|
| 50 |
+
response_started = True
|
| 51 |
+
await send({
|
| 52 |
+
"type": "http.response.start",
|
| 53 |
+
"status": 413,
|
| 54 |
+
"headers": [
|
| 55 |
+
(b"content-type", b"application/json"),
|
| 56 |
+
(b"cache-control", b"no-store"),
|
| 57 |
+
],
|
| 58 |
+
})
|
| 59 |
+
return
|
| 60 |
+
response_started = True
|
| 61 |
+
if exceeded:
|
| 62 |
+
if message["type"] == "http.response.body" and not response_started:
|
| 63 |
+
return
|
| 64 |
+
if message["type"] == "http.response.body":
|
| 65 |
+
await send({"type": "http.response.body", "body": b'{"detail":"Request body too large"}', "more_body": False})
|
| 66 |
+
return
|
| 67 |
+
await send(message)
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
await self.app(scope, limited_receive, guarded_send)
|
| 71 |
+
except Exception:
|
| 72 |
+
if exceeded and not response_started:
|
| 73 |
+
await self._too_large(scope, receive, send)
|
| 74 |
+
return
|
| 75 |
+
raise
|
| 76 |
+
|
| 77 |
+
async def _too_large(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 78 |
+
body = b'{"detail":"Request body too large"}'
|
| 79 |
+
await send({
|
| 80 |
+
"type": "http.response.start",
|
| 81 |
+
"status": 413,
|
| 82 |
+
"headers": [
|
| 83 |
+
(b"content-type", b"application/json"),
|
| 84 |
+
(b"content-length", str(len(body)).encode("ascii")),
|
| 85 |
+
(b"cache-control", b"no-store"),
|
| 86 |
+
],
|
| 87 |
+
})
|
| 88 |
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
app/operations/mix_audio.py
CHANGED
|
@@ -138,3 +138,57 @@ async def add_audio(
|
|
| 138 |
"duration_seconds": duration,
|
| 139 |
},
|
| 140 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
"duration_seconds": duration,
|
| 139 |
},
|
| 140 |
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
async def duck_background_music(
|
| 144 |
+
ffmpeg: FFmpegService,
|
| 145 |
+
inputs: Sequence[InputMedia],
|
| 146 |
+
params: dict[str, Any],
|
| 147 |
+
output_dir: Path,
|
| 148 |
+
) -> OperationResult:
|
| 149 |
+
"""Mix background music under the video's narration using side-chain compression."""
|
| 150 |
+
require_inputs(inputs, 2)
|
| 151 |
+
video, music = inputs[0], inputs[1]
|
| 152 |
+
duration = _video_duration(inputs)
|
| 153 |
+
if not _has_audio(video):
|
| 154 |
+
raise InputError("Background-music ducking requires narration audio in the source video")
|
| 155 |
+
music_volume = bounded_number(params, "music_volume", 0.22, 0, 2)
|
| 156 |
+
voice_volume = bounded_number(params, "voice_volume", 1, 0, 4)
|
| 157 |
+
threshold = bounded_number(params, "threshold", 0.03, 0.001, 1)
|
| 158 |
+
ratio = bounded_number(params, "ratio", 8, 1, 20)
|
| 159 |
+
attack = bounded_number(params, "attack", 20, 1, 2000)
|
| 160 |
+
release = bounded_number(params, "release", 300, 10, 5000)
|
| 161 |
+
bitrate = _audio_bitrate(params)
|
| 162 |
+
duration_value = f"{duration:.6f}"
|
| 163 |
+
filters = (
|
| 164 |
+
f"[0:a]apad=whole_dur={duration_value},atrim=duration={duration_value},"
|
| 165 |
+
f"volume={voice_volume:.6g}[voice];"
|
| 166 |
+
f"[1:a]apad=whole_dur={duration_value},atrim=duration={duration_value},"
|
| 167 |
+
f"volume={music_volume:.6g}[music];"
|
| 168 |
+
f"[music][voice]sidechaincompress=threshold={threshold:.6g}:"
|
| 169 |
+
f"ratio={ratio:.6g}:attack={attack:.6g}:release={release:.6g}[ducked];"
|
| 170 |
+
f"[voice][ducked]amix=inputs=2:duration=first:normalize=0[mixed]"
|
| 171 |
+
)
|
| 172 |
+
output = output_path(output_dir, "ducked-audio", "mp4")
|
| 173 |
+
args: list[str | Path] = []
|
| 174 |
+
for media in inputs:
|
| 175 |
+
args.extend(["-i", media.temp_path])
|
| 176 |
+
args.extend([
|
| 177 |
+
"-filter_complex", filters, "-map", "0:v:0", "-map", "[mixed]",
|
| 178 |
+
"-c:v", "copy", "-c:a", "aac", "-b:a", bitrate,
|
| 179 |
+
"-movflags", "+faststart", output,
|
| 180 |
+
])
|
| 181 |
+
return await execute(
|
| 182 |
+
ffmpeg, args, output, "video.duck_background_music",
|
| 183 |
+
{
|
| 184 |
+
"preserve_video": True,
|
| 185 |
+
"mix_mode": "sidechain_ducking",
|
| 186 |
+
"music_volume": music_volume,
|
| 187 |
+
"voice_volume": voice_volume,
|
| 188 |
+
"threshold": threshold,
|
| 189 |
+
"ratio": ratio,
|
| 190 |
+
"attack_ms": attack,
|
| 191 |
+
"release_ms": release,
|
| 192 |
+
"duration_seconds": duration,
|
| 193 |
+
},
|
| 194 |
+
)
|
app/services/cleanup.py
CHANGED
|
@@ -58,9 +58,7 @@ class CleanupService:
|
|
| 58 |
|
| 59 |
async def publish(self, request_id: str, source: Path, filename: str) -> Path:
|
| 60 |
self._validate_request_id(request_id)
|
| 61 |
-
safe_name =
|
| 62 |
-
if not safe_name or safe_name in {".", ".."}:
|
| 63 |
-
raise ProcessingError("The generated output filename is invalid")
|
| 64 |
destination_dir = self.settings.output_dir / request_id
|
| 65 |
destination_dir.mkdir(parents=True, exist_ok=True)
|
| 66 |
destination = destination_dir / safe_name
|
|
@@ -71,18 +69,9 @@ class CleanupService:
|
|
| 71 |
return destination
|
| 72 |
|
| 73 |
async def publish_new(self, request_id: str, source: Path, filename: str) -> Path | None:
|
| 74 |
-
"""Publish a generated output without replacing an existing file.
|
| 75 |
-
|
| 76 |
-
This is used by durable generation-job reconciliation, where a retry
|
| 77 |
-
must never overwrite a canonical output created by an earlier worker
|
| 78 |
-
attempt. Existing media operations continue to use ``publish`` and
|
| 79 |
-
retain their established replacement semantics.
|
| 80 |
-
"""
|
| 81 |
-
|
| 82 |
self._validate_request_id(request_id)
|
| 83 |
-
safe_name =
|
| 84 |
-
if not safe_name or safe_name in {".", ".."}:
|
| 85 |
-
raise ProcessingError("The generated output filename is invalid")
|
| 86 |
destination_dir = self.settings.output_dir / request_id
|
| 87 |
destination_dir.mkdir(parents=True, exist_ok=True)
|
| 88 |
destination = destination_dir / safe_name
|
|
@@ -92,22 +81,17 @@ class CleanupService:
|
|
| 92 |
@staticmethod
|
| 93 |
def _publish_new_sync(source: Path, destination: Path) -> bool:
|
| 94 |
"""Atomically claim a destination, with a cross-device fallback."""
|
| 95 |
-
|
| 96 |
try:
|
| 97 |
os.link(source, destination)
|
| 98 |
except FileExistsError:
|
| 99 |
return False
|
| 100 |
except OSError:
|
| 101 |
-
# ``temp_dir`` and ``output_dir`` can be different mounts. An
|
| 102 |
-
# exclusive create still prevents overwrite in that arrangement.
|
| 103 |
try:
|
| 104 |
with source.open("rb") as input_stream, destination.open("xb") as output_stream:
|
| 105 |
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
|
| 106 |
except FileExistsError:
|
| 107 |
return False
|
| 108 |
except OSError:
|
| 109 |
-
# Never leave a partial file reachable from the output
|
| 110 |
-
# directory when cross-device publication fails.
|
| 111 |
try:
|
| 112 |
destination.unlink(missing_ok=True)
|
| 113 |
except OSError:
|
|
@@ -121,11 +105,20 @@ class CleanupService:
|
|
| 121 |
|
| 122 |
def resolve_download(self, request_id: str, filename: str) -> Path:
|
| 123 |
self._validate_request_id(request_id)
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
raise NotFoundError("Output file not found")
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
raise NotFoundError("Output file not found")
|
| 130 |
return candidate
|
| 131 |
|
|
@@ -133,8 +126,8 @@ class CleanupService:
|
|
| 133 |
cutoff = time.time() - self.settings.cleanup_minutes * 60
|
| 134 |
async with self._lock:
|
| 135 |
active = self._active.copy()
|
| 136 |
-
removed = 0
|
| 137 |
protected = protected_request_ids or set()
|
|
|
|
| 138 |
for base in (self.settings.temp_dir, self.settings.output_dir):
|
| 139 |
if not base.exists():
|
| 140 |
continue
|
|
@@ -179,3 +172,18 @@ class CleanupService:
|
|
| 179 |
UUID(request_id)
|
| 180 |
except (ValueError, AttributeError) as exc:
|
| 181 |
raise NotFoundError("Output file not found") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
async def publish(self, request_id: str, source: Path, filename: str) -> Path:
|
| 60 |
self._validate_request_id(request_id)
|
| 61 |
+
safe_name = self._validate_filename(filename)
|
|
|
|
|
|
|
| 62 |
destination_dir = self.settings.output_dir / request_id
|
| 63 |
destination_dir.mkdir(parents=True, exist_ok=True)
|
| 64 |
destination = destination_dir / safe_name
|
|
|
|
| 69 |
return destination
|
| 70 |
|
| 71 |
async def publish_new(self, request_id: str, source: Path, filename: str) -> Path | None:
|
| 72 |
+
"""Publish a generated output without replacing an existing file."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
self._validate_request_id(request_id)
|
| 74 |
+
safe_name = self._validate_filename(filename)
|
|
|
|
|
|
|
| 75 |
destination_dir = self.settings.output_dir / request_id
|
| 76 |
destination_dir.mkdir(parents=True, exist_ok=True)
|
| 77 |
destination = destination_dir / safe_name
|
|
|
|
| 81 |
@staticmethod
|
| 82 |
def _publish_new_sync(source: Path, destination: Path) -> bool:
|
| 83 |
"""Atomically claim a destination, with a cross-device fallback."""
|
|
|
|
| 84 |
try:
|
| 85 |
os.link(source, destination)
|
| 86 |
except FileExistsError:
|
| 87 |
return False
|
| 88 |
except OSError:
|
|
|
|
|
|
|
| 89 |
try:
|
| 90 |
with source.open("rb") as input_stream, destination.open("xb") as output_stream:
|
| 91 |
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
|
| 92 |
except FileExistsError:
|
| 93 |
return False
|
| 94 |
except OSError:
|
|
|
|
|
|
|
| 95 |
try:
|
| 96 |
destination.unlink(missing_ok=True)
|
| 97 |
except OSError:
|
|
|
|
| 105 |
|
| 106 |
def resolve_download(self, request_id: str, filename: str) -> Path:
|
| 107 |
self._validate_request_id(request_id)
|
| 108 |
+
safe_name = self._validate_filename(filename)
|
| 109 |
+
base = self.settings.output_dir.resolve()
|
| 110 |
+
request_root = base / request_id
|
| 111 |
+
# Do not follow a request-directory symlink out of the configured
|
| 112 |
+
# output root, even if the resulting file happens to exist.
|
| 113 |
+
if request_root.is_symlink() or not request_root.is_dir():
|
| 114 |
raise NotFoundError("Output file not found")
|
| 115 |
+
candidate_unresolved = request_root / safe_name
|
| 116 |
+
# A published artifact must be a regular file created in this exact
|
| 117 |
+
# request directory, not a symlink to another artifact or host file.
|
| 118 |
+
if candidate_unresolved.is_symlink():
|
| 119 |
+
raise NotFoundError("Output file not found")
|
| 120 |
+
candidate = candidate_unresolved.resolve()
|
| 121 |
+
if candidate.parent != request_root or candidate.name != safe_name or not candidate.is_file():
|
| 122 |
raise NotFoundError("Output file not found")
|
| 123 |
return candidate
|
| 124 |
|
|
|
|
| 126 |
cutoff = time.time() - self.settings.cleanup_minutes * 60
|
| 127 |
async with self._lock:
|
| 128 |
active = self._active.copy()
|
|
|
|
| 129 |
protected = protected_request_ids or set()
|
| 130 |
+
removed = 0
|
| 131 |
for base in (self.settings.temp_dir, self.settings.output_dir):
|
| 132 |
if not base.exists():
|
| 133 |
continue
|
|
|
|
| 172 |
UUID(request_id)
|
| 173 |
except (ValueError, AttributeError) as exc:
|
| 174 |
raise NotFoundError("Output file not found") from exc
|
| 175 |
+
|
| 176 |
+
@staticmethod
|
| 177 |
+
def _validate_filename(filename: str) -> str:
|
| 178 |
+
# Keep URL path parameters and Content-Disposition headers unambiguous.
|
| 179 |
+
if (
|
| 180 |
+
not filename
|
| 181 |
+
or filename in {".", ".."}
|
| 182 |
+
or Path(filename).name != filename
|
| 183 |
+
or "/" in filename
|
| 184 |
+
or "\\" in filename
|
| 185 |
+
or any(ord(char) < 32 or ord(char) == 127 for char in filename)
|
| 186 |
+
or '"' in filename
|
| 187 |
+
):
|
| 188 |
+
raise NotFoundError("Output file not found")
|
| 189 |
+
return filename
|
app/services/downloader.py
CHANGED
|
@@ -16,27 +16,66 @@ from app.core.exceptions import DownloadError, InputError
|
|
| 16 |
from app.services.validator import MediaValidator
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
class Downloader:
|
| 20 |
-
"""Size-limited streaming HTTP(S) downloader with SSRF protection."""
|
| 21 |
|
| 22 |
def __init__(self, settings: Settings, validator: MediaValidator) -> None:
|
| 23 |
self.settings = settings
|
| 24 |
self.validator = validator
|
|
|
|
| 25 |
|
| 26 |
-
async def download(
|
| 27 |
-
self, url: str, destination_dir: Path, filename: str | None = None
|
| 28 |
-
) -> tuple[Path, str]:
|
| 29 |
current_url = url
|
| 30 |
timeout = httpx.Timeout(self.settings.download_timeout_seconds, connect=30.0)
|
| 31 |
headers = {"User-Agent": "MediaAPI/1.0"}
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
for _ in range(6):
|
| 34 |
await self.validate_url(current_url)
|
| 35 |
destination: Path | None = None
|
| 36 |
try:
|
| 37 |
-
async with client.stream(
|
| 38 |
-
"GET", current_url, follow_redirects=False
|
| 39 |
-
) as response:
|
| 40 |
if response.status_code in {301, 302, 303, 307, 308}:
|
| 41 |
location = response.headers.get("location")
|
| 42 |
if not location:
|
|
@@ -45,21 +84,15 @@ class Downloader:
|
|
| 45 |
continue
|
| 46 |
response.raise_for_status()
|
| 47 |
declared = self._content_length(response.headers.get("content-length"))
|
| 48 |
-
if declared and declared > self.settings.max_upload_size:
|
| 49 |
raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
|
| 50 |
-
name = self.validator.safe_filename(
|
| 51 |
-
|
| 52 |
-
"download.bin",
|
| 53 |
-
)
|
| 54 |
-
unique_name = f"{uuid4().hex}_{name}"
|
| 55 |
-
destination = destination_dir / unique_name
|
| 56 |
size = 0
|
| 57 |
async with aiofiles.open(destination, "wb") as output:
|
| 58 |
async for chunk in response.aiter_bytes(1024 * 1024):
|
| 59 |
size += len(chunk)
|
| 60 |
if size > self.settings.max_upload_size:
|
| 61 |
-
await output.close()
|
| 62 |
-
destination.unlink(missing_ok=True)
|
| 63 |
raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
|
| 64 |
await output.write(chunk)
|
| 65 |
mime_type = response.headers.get("content-type", "").split(";", 1)[0]
|
|
@@ -69,10 +102,7 @@ class Downloader:
|
|
| 69 |
except httpx.HTTPStatusError as exc:
|
| 70 |
if destination is not None:
|
| 71 |
destination.unlink(missing_ok=True)
|
| 72 |
-
raise DownloadError(
|
| 73 |
-
"Remote server rejected the download",
|
| 74 |
-
details={"status_code": exc.response.status_code},
|
| 75 |
-
) from exc
|
| 76 |
except httpx.HTTPError as exc:
|
| 77 |
if destination is not None:
|
| 78 |
destination.unlink(missing_ok=True)
|
|
@@ -88,28 +118,46 @@ class Downloader:
|
|
| 88 |
raise DownloadError("Too many redirects while downloading media")
|
| 89 |
|
| 90 |
async def validate_url(self, value: str) -> None:
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if self.settings.allow_private_urls:
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
try:
|
| 99 |
-
records = await asyncio.to_thread(
|
| 100 |
-
|
| 101 |
-
)
|
| 102 |
-
except socket.gaierror as exc:
|
| 103 |
raise DownloadError("The remote hostname could not be resolved") from exc
|
|
|
|
|
|
|
|
|
|
| 104 |
for record in records:
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
raise InputError("Private, loopback, and link-local URLs are not allowed")
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
@staticmethod
|
| 110 |
def _response_filename(response: httpx.Response, url: str) -> str:
|
| 111 |
disposition = response.headers.get("content-disposition", "")
|
| 112 |
-
match = re.search(r"filename\*?=(?:UTF-8''|\")?([^\";]+)", disposition, re.I)
|
| 113 |
if match:
|
| 114 |
return unquote(match.group(1).strip())
|
| 115 |
path_name = Path(unquote(urlparse(url).path)).name
|
|
@@ -117,7 +165,10 @@ class Downloader:
|
|
| 117 |
|
| 118 |
@staticmethod
|
| 119 |
def _content_length(value: str | None) -> int | None:
|
|
|
|
|
|
|
| 120 |
try:
|
| 121 |
-
|
| 122 |
-
except ValueError:
|
| 123 |
return None
|
|
|
|
|
|
| 16 |
from app.services.validator import MediaValidator
|
| 17 |
|
| 18 |
|
| 19 |
+
class _PinnedNetworkBackend:
|
| 20 |
+
"""httpcore network backend that dials only DNS-validated IPs.
|
| 21 |
+
|
| 22 |
+
TLS negotiation still receives the original URL hostname from httpcore,
|
| 23 |
+
preserving SNI and certificate hostname verification.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, downloader: "Downloader", fallback: object) -> None:
|
| 27 |
+
self.downloader = downloader
|
| 28 |
+
self.fallback = fallback
|
| 29 |
+
|
| 30 |
+
async def connect_tcp(self, host: str, port: int, timeout: float | None = None, local_address: str | None = None, socket_options=None):
|
| 31 |
+
pinned = self.downloader._pinned.get((host.lower().rstrip("."), port))
|
| 32 |
+
target = pinned[0] if pinned else host
|
| 33 |
+
return await self.fallback.connect_tcp(
|
| 34 |
+
host=target,
|
| 35 |
+
port=port,
|
| 36 |
+
timeout=timeout,
|
| 37 |
+
local_address=local_address,
|
| 38 |
+
socket_options=socket_options,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
async def connect_unix_socket(self, path: str, timeout: float | None = None, socket_options=None):
|
| 42 |
+
return await self.fallback.connect_unix_socket(path, timeout=timeout, socket_options=socket_options)
|
| 43 |
+
|
| 44 |
+
async def sleep(self, seconds: float) -> None:
|
| 45 |
+
await self.fallback.sleep(seconds)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
class Downloader:
|
| 49 |
+
"""Size-limited streaming HTTP(S) downloader with connection-pinned SSRF protection."""
|
| 50 |
|
| 51 |
def __init__(self, settings: Settings, validator: MediaValidator) -> None:
|
| 52 |
self.settings = settings
|
| 53 |
self.validator = validator
|
| 54 |
+
self._pinned: dict[tuple[str, int], tuple[str, ...]] = {}
|
| 55 |
|
| 56 |
+
async def download(self, url: str, destination_dir: Path, filename: str | None = None) -> tuple[Path, str]:
|
|
|
|
|
|
|
| 57 |
current_url = url
|
| 58 |
timeout = httpx.Timeout(self.settings.download_timeout_seconds, connect=30.0)
|
| 59 |
headers = {"User-Agent": "MediaAPI/1.0"}
|
| 60 |
+
transport = httpx.AsyncHTTPTransport(retries=0)
|
| 61 |
+
# HTTPX 0.28 uses httpcore's network backend for socket creation. The
|
| 62 |
+
# wrapper pins the TCP destination while httpcore retains the URL host
|
| 63 |
+
# for TLS SNI, certificate checks, and the HTTP Host header.
|
| 64 |
+
try:
|
| 65 |
+
from httpcore._backends.auto import AutoBackend
|
| 66 |
+
transport._pool._network_backend = _PinnedNetworkBackend(self, AutoBackend())
|
| 67 |
+
except (AttributeError, ImportError) as exc:
|
| 68 |
+
raise DownloadError("Secure connection-pinned downloads are unavailable in this runtime") from exc
|
| 69 |
+
|
| 70 |
+
async with httpx.AsyncClient(
|
| 71 |
+
timeout=timeout, headers=headers, follow_redirects=False,
|
| 72 |
+
trust_env=False, transport=transport,
|
| 73 |
+
) as client:
|
| 74 |
for _ in range(6):
|
| 75 |
await self.validate_url(current_url)
|
| 76 |
destination: Path | None = None
|
| 77 |
try:
|
| 78 |
+
async with client.stream("GET", current_url, follow_redirects=False) as response:
|
|
|
|
|
|
|
| 79 |
if response.status_code in {301, 302, 303, 307, 308}:
|
| 80 |
location = response.headers.get("location")
|
| 81 |
if not location:
|
|
|
|
| 84 |
continue
|
| 85 |
response.raise_for_status()
|
| 86 |
declared = self._content_length(response.headers.get("content-length"))
|
| 87 |
+
if declared is not None and declared > self.settings.max_upload_size:
|
| 88 |
raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
|
| 89 |
+
name = self.validator.safe_filename(filename or self._response_filename(response, current_url), "download.bin")
|
| 90 |
+
destination = destination_dir / f"{uuid4().hex}_{name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
size = 0
|
| 92 |
async with aiofiles.open(destination, "wb") as output:
|
| 93 |
async for chunk in response.aiter_bytes(1024 * 1024):
|
| 94 |
size += len(chunk)
|
| 95 |
if size > self.settings.max_upload_size:
|
|
|
|
|
|
|
| 96 |
raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
|
| 97 |
await output.write(chunk)
|
| 98 |
mime_type = response.headers.get("content-type", "").split(";", 1)[0]
|
|
|
|
| 102 |
except httpx.HTTPStatusError as exc:
|
| 103 |
if destination is not None:
|
| 104 |
destination.unlink(missing_ok=True)
|
| 105 |
+
raise DownloadError("Remote server rejected the download", details={"status_code": exc.response.status_code}) from exc
|
|
|
|
|
|
|
|
|
|
| 106 |
except httpx.HTTPError as exc:
|
| 107 |
if destination is not None:
|
| 108 |
destination.unlink(missing_ok=True)
|
|
|
|
| 118 |
raise DownloadError("Too many redirects while downloading media")
|
| 119 |
|
| 120 |
async def validate_url(self, value: str) -> None:
|
| 121 |
+
try:
|
| 122 |
+
parsed = urlparse(value)
|
| 123 |
+
if (
|
| 124 |
+
parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname
|
| 125 |
+
or parsed.username is not None or parsed.password is not None
|
| 126 |
+
or any(char.isspace() for char in parsed.hostname)
|
| 127 |
+
):
|
| 128 |
+
raise InputError("Only credential-free HTTP and HTTPS media URLs are supported")
|
| 129 |
+
port = parsed.port
|
| 130 |
+
except ValueError as exc:
|
| 131 |
+
raise InputError("Media URL contains an invalid host or port") from exc
|
| 132 |
+
|
| 133 |
+
hostname = parsed.hostname.encode("idna").decode("ascii").lower().rstrip(".")
|
| 134 |
+
target_port = port or (443 if parsed.scheme.lower() == "https" else 80)
|
| 135 |
if self.settings.allow_private_urls:
|
| 136 |
+
# Explicit opt-in permits private hosts; still pin the current DNS
|
| 137 |
+
# result to prevent a second, inconsistent lookup by the HTTP client.
|
| 138 |
+
pass
|
| 139 |
try:
|
| 140 |
+
records = await asyncio.to_thread(socket.getaddrinfo, hostname, target_port, type=socket.SOCK_STREAM)
|
| 141 |
+
except (socket.gaierror, OSError) as exc:
|
|
|
|
|
|
|
| 142 |
raise DownloadError("The remote hostname could not be resolved") from exc
|
| 143 |
+
if not records:
|
| 144 |
+
raise DownloadError("The remote hostname has no usable address")
|
| 145 |
+
addresses: list[str] = []
|
| 146 |
for record in records:
|
| 147 |
+
try:
|
| 148 |
+
address = ipaddress.ip_address(record[4][0].split("%", 1)[0])
|
| 149 |
+
except (ValueError, IndexError, TypeError) as exc:
|
| 150 |
+
raise DownloadError("The remote hostname resolved to an invalid address") from exc
|
| 151 |
+
if not self.settings.allow_private_urls and not address.is_global:
|
| 152 |
raise InputError("Private, loopback, and link-local URLs are not allowed")
|
| 153 |
+
if str(address) not in addresses:
|
| 154 |
+
addresses.append(str(address))
|
| 155 |
+
self._pinned[(hostname, target_port)] = tuple(addresses)
|
| 156 |
|
| 157 |
@staticmethod
|
| 158 |
def _response_filename(response: httpx.Response, url: str) -> str:
|
| 159 |
disposition = response.headers.get("content-disposition", "")
|
| 160 |
+
match = re.search(r"filename\\*?=(?:UTF-8''|\\")?([^\\\";]+)", disposition, re.I)
|
| 161 |
if match:
|
| 162 |
return unquote(match.group(1).strip())
|
| 163 |
path_name = Path(unquote(urlparse(url).path)).name
|
|
|
|
| 165 |
|
| 166 |
@staticmethod
|
| 167 |
def _content_length(value: str | None) -> int | None:
|
| 168 |
+
if value is None:
|
| 169 |
+
return None
|
| 170 |
try:
|
| 171 |
+
parsed = int(value)
|
| 172 |
+
except (TypeError, ValueError):
|
| 173 |
return None
|
| 174 |
+
return parsed if parsed >= 0 else None
|
app/services/media_service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
import os
|
| 5 |
import time
|
| 6 |
from collections.abc import Awaitable, Callable, Sequence
|
|
@@ -8,6 +9,7 @@ from pathlib import Path
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
from app.core.config import Settings
|
|
|
|
| 11 |
from app.core.logger import get_logger
|
| 12 |
from app.core.response import SuccessResponse
|
| 13 |
from app.models.media import InputMedia, OperationResult, ResolvedRequest
|
|
@@ -244,6 +246,8 @@ class MediaProcessor:
|
|
| 244 |
output_size = 0
|
| 245 |
canonical_asset_id = None
|
| 246 |
if result.path is not None:
|
|
|
|
|
|
|
| 247 |
published = await self.cleanup.publish(
|
| 248 |
resolved.request_id, result.path, result.filename or result.path.name
|
| 249 |
)
|
|
@@ -291,6 +295,75 @@ class MediaProcessor:
|
|
| 291 |
metadata=metadata,
|
| 292 |
)
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
@staticmethod
|
| 295 |
def _optional_int(
|
| 296 |
value: Any, name: str, minimum: int, maximum: int
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import math
|
| 5 |
import os
|
| 6 |
import time
|
| 7 |
from collections.abc import Awaitable, Callable, Sequence
|
|
|
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
from app.core.config import Settings
|
| 12 |
+
from app.core.exceptions import ProcessingError
|
| 13 |
from app.core.logger import get_logger
|
| 14 |
from app.core.response import SuccessResponse
|
| 15 |
from app.models.media import InputMedia, OperationResult, ResolvedRequest
|
|
|
|
| 246 |
output_size = 0
|
| 247 |
canonical_asset_id = None
|
| 248 |
if result.path is not None:
|
| 249 |
+
output_report = await self.validate_output_result(result)
|
| 250 |
+
result.metadata = {**result.metadata, "output_validation": output_report}
|
| 251 |
published = await self.cleanup.publish(
|
| 252 |
resolved.request_id, result.path, result.filename or result.path.name
|
| 253 |
)
|
|
|
|
| 295 |
metadata=metadata,
|
| 296 |
)
|
| 297 |
|
| 298 |
+
async def validate_output_result(self, result: OperationResult) -> dict[str, Any]:
|
| 299 |
+
"""Validate every published media artifact, regardless of which API produced it."""
|
| 300 |
+
path = result.path
|
| 301 |
+
if path is None:
|
| 302 |
+
return {"valid": True, "file_artifact": False}
|
| 303 |
+
if not path.is_file():
|
| 304 |
+
raise ProcessingError("Output artifact is missing")
|
| 305 |
+
try:
|
| 306 |
+
size = path.stat().st_size
|
| 307 |
+
except OSError as exc:
|
| 308 |
+
raise ProcessingError("Unable to inspect output artifact") from exc
|
| 309 |
+
if size <= 0:
|
| 310 |
+
raise ProcessingError("Output artifact is empty")
|
| 311 |
+
|
| 312 |
+
mime_type = result.mime_type or self.validator.infer_mime(path)
|
| 313 |
+
report: dict[str, Any] = {
|
| 314 |
+
"valid": True,
|
| 315 |
+
"file_artifact": True,
|
| 316 |
+
"size_bytes": size,
|
| 317 |
+
"mime_type": mime_type,
|
| 318 |
+
"probed": False,
|
| 319 |
+
"container": None,
|
| 320 |
+
"duration_seconds": None,
|
| 321 |
+
"video_stream_count": None,
|
| 322 |
+
"audio_stream_count": None,
|
| 323 |
+
"resolution": None,
|
| 324 |
+
}
|
| 325 |
+
if not mime_type.startswith(("audio/", "video/", "image/")):
|
| 326 |
+
return report
|
| 327 |
+
|
| 328 |
+
try:
|
| 329 |
+
probe = await self.ffprobe.probe(path)
|
| 330 |
+
except Exception as exc:
|
| 331 |
+
if isinstance(exc, ProcessingError):
|
| 332 |
+
raise
|
| 333 |
+
raise ProcessingError("Output media failed integrity validation") from exc
|
| 334 |
+
|
| 335 |
+
video_streams = probe.get("video_streams") or []
|
| 336 |
+
audio_streams = probe.get("audio_streams") or []
|
| 337 |
+
if mime_type.startswith("video/") and not video_streams:
|
| 338 |
+
raise ProcessingError("Output is declared as video but contains no video stream")
|
| 339 |
+
if mime_type.startswith("audio/") and not audio_streams:
|
| 340 |
+
raise ProcessingError("Output is declared as audio but contains no audio stream")
|
| 341 |
+
if mime_type.startswith("image/") and not (video_streams or audio_streams):
|
| 342 |
+
raise ProcessingError("Output is declared as an image but contains no decodable stream")
|
| 343 |
+
try:
|
| 344 |
+
self.validator.validate_probe(probe)
|
| 345 |
+
except Exception as exc:
|
| 346 |
+
raise ProcessingError("Output media failed integrity validation") from exc
|
| 347 |
+
|
| 348 |
+
duration = probe.get("duration")
|
| 349 |
+
if mime_type.startswith(("audio/", "video/")):
|
| 350 |
+
try:
|
| 351 |
+
valid_duration = duration is not None and math.isfinite(float(duration)) and float(duration) > 0
|
| 352 |
+
except (TypeError, ValueError):
|
| 353 |
+
valid_duration = False
|
| 354 |
+
if not valid_duration:
|
| 355 |
+
raise ProcessingError("Output media has an invalid duration")
|
| 356 |
+
|
| 357 |
+
report.update({
|
| 358 |
+
"probed": True,
|
| 359 |
+
"container": probe.get("container"),
|
| 360 |
+
"duration_seconds": duration,
|
| 361 |
+
"video_stream_count": len(video_streams),
|
| 362 |
+
"audio_stream_count": len(audio_streams),
|
| 363 |
+
"resolution": probe.get("resolution"),
|
| 364 |
+
})
|
| 365 |
+
return report
|
| 366 |
+
|
| 367 |
@staticmethod
|
| 368 |
def _optional_int(
|
| 369 |
value: Any, name: str, minimum: int, maximum: int
|
app/services/media_url_signer.py
CHANGED
|
@@ -24,7 +24,11 @@ class MediaURLSigner:
|
|
| 24 |
key = self._key()
|
| 25 |
if key is None:
|
| 26 |
return None
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
|
| 29 |
digest = hmac.new(key, message, hashlib.sha256).digest()
|
| 30 |
token = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
@@ -33,8 +37,15 @@ class MediaURLSigner:
|
|
| 33 |
|
| 34 |
def verify(self, request_id: str, filename: str, expires: int, signature: str) -> bool:
|
| 35 |
key = self._key()
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return False
|
| 38 |
message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
|
| 39 |
-
expected = base64.urlsafe_b64encode(
|
|
|
|
|
|
|
| 40 |
return hmac.compare_digest(expected, signature)
|
|
|
|
| 24 |
key = self._key()
|
| 25 |
if key is None:
|
| 26 |
return None
|
| 27 |
+
requested_ttl = self.settings.media_signed_url_ttl_seconds if ttl is None else ttl
|
| 28 |
+
# A caller may request a shorter link, but cannot mint a link longer
|
| 29 |
+
# than the configured maximum lifetime.
|
| 30 |
+
lifetime = max(1, min(int(requested_ttl), self.settings.media_signed_url_ttl_seconds))
|
| 31 |
+
expires = int(time.time()) + lifetime
|
| 32 |
message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
|
| 33 |
digest = hmac.new(key, message, hashlib.sha256).digest()
|
| 34 |
token = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
|
|
| 37 |
|
| 38 |
def verify(self, request_id: str, filename: str, expires: int, signature: str) -> bool:
|
| 39 |
key = self._key()
|
| 40 |
+
now = int(time.time())
|
| 41 |
+
if (
|
| 42 |
+
key is None
|
| 43 |
+
or expires <= now
|
| 44 |
+
or expires > now + self.settings.media_signed_url_ttl_seconds
|
| 45 |
+
):
|
| 46 |
return False
|
| 47 |
message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
|
| 48 |
+
expected = base64.urlsafe_b64encode(
|
| 49 |
+
hmac.new(key, message, hashlib.sha256).digest()
|
| 50 |
+
).decode("ascii").rstrip("=")
|
| 51 |
return hmac.compare_digest(expected, signature)
|
app/services/validator.py
CHANGED
|
@@ -9,44 +9,11 @@ from app.core.config import Settings
|
|
| 9 |
from app.core.exceptions import InputError
|
| 10 |
|
| 11 |
MEDIA_EXTENSIONS = {
|
| 12 |
-
".3gp",
|
| 13 |
-
".
|
| 14 |
-
".
|
| 15 |
-
".
|
| 16 |
-
".
|
| 17 |
-
".bin",
|
| 18 |
-
".bmp",
|
| 19 |
-
".flac",
|
| 20 |
-
".gif",
|
| 21 |
-
".heic",
|
| 22 |
-
".jpeg",
|
| 23 |
-
".jpg",
|
| 24 |
-
".m4a",
|
| 25 |
-
".m4v",
|
| 26 |
-
".mkv",
|
| 27 |
-
".m3u",
|
| 28 |
-
".m3u8",
|
| 29 |
-
".mov",
|
| 30 |
-
".mp3",
|
| 31 |
-
".mp4",
|
| 32 |
-
".mpeg",
|
| 33 |
-
".mpg",
|
| 34 |
-
".oga",
|
| 35 |
-
".ogg",
|
| 36 |
-
".opus",
|
| 37 |
-
".png",
|
| 38 |
-
".svg",
|
| 39 |
-
".tif",
|
| 40 |
-
".tiff",
|
| 41 |
-
".ts",
|
| 42 |
-
".vtt",
|
| 43 |
-
".wav",
|
| 44 |
-
".webm",
|
| 45 |
-
".webp",
|
| 46 |
-
".wmv",
|
| 47 |
-
".srt",
|
| 48 |
-
".ass",
|
| 49 |
-
".ssa",
|
| 50 |
}
|
| 51 |
|
| 52 |
|
|
@@ -55,8 +22,16 @@ class MediaValidator:
|
|
| 55 |
self.settings = settings
|
| 56 |
|
| 57 |
def safe_filename(self, filename: str | None, fallback: str = "input.bin") -> str:
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
raise InputError("Invalid filename")
|
| 61 |
return name[:240]
|
| 62 |
|
|
@@ -72,22 +47,15 @@ class MediaValidator:
|
|
| 72 |
if suffix and suffix not in MEDIA_EXTENSIONS:
|
| 73 |
raise InputError("Unsupported media file extension", details={"extension": suffix})
|
| 74 |
normalized_mime = mime_type.split(";", 1)[0].lower()
|
| 75 |
-
allowed = normalized_mime.startswith(
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
"application/octet-stream",
|
| 79 |
-
"application/x-subrip",
|
| 80 |
-
"application/vnd.apple.mpegurl",
|
| 81 |
-
"application/x-mpegurl",
|
| 82 |
}
|
| 83 |
if normalized_mime and not allowed:
|
| 84 |
raise InputError("Unsupported media MIME type", details={"mime_type": mime_type})
|
| 85 |
|
| 86 |
def validate_probe(self, metadata: dict[str, Any]) -> None:
|
| 87 |
-
streams = [
|
| 88 |
-
*(metadata.get("video_streams") or []),
|
| 89 |
-
*(metadata.get("audio_streams") or []),
|
| 90 |
-
]
|
| 91 |
if not streams:
|
| 92 |
raise InputError("The input has no decodable video or audio stream")
|
| 93 |
for stream in streams:
|
|
|
|
| 9 |
from app.core.exceptions import InputError
|
| 10 |
|
| 11 |
MEDIA_EXTENSIONS = {
|
| 12 |
+
".3gp", ".aac", ".aiff", ".apng", ".avi", ".bin", ".bmp", ".flac",
|
| 13 |
+
".gif", ".heic", ".jpeg", ".jpg", ".m4a", ".m4v", ".mkv", ".m3u",
|
| 14 |
+
".m3u8", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".oga", ".ogg",
|
| 15 |
+
".opus", ".png", ".svg", ".tif", ".tiff", ".ts", ".vtt", ".wav",
|
| 16 |
+
".webm", ".webp", ".wmv", ".srt", ".ass", ".ssa",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
|
|
|
|
| 22 |
self.settings = settings
|
| 23 |
|
| 24 |
def safe_filename(self, filename: str | None, fallback: str = "input.bin") -> str:
|
| 25 |
+
# Treat both POSIX and Windows separators as path delimiters regardless
|
| 26 |
+
# of the server OS; never allow a client path to become a stored name.
|
| 27 |
+
raw = str(filename or fallback).replace("\\", "/")
|
| 28 |
+
name = raw.rsplit("/", 1)[-1].replace("\x00", "")
|
| 29 |
+
if (
|
| 30 |
+
not name
|
| 31 |
+
or name in {".", ".."}
|
| 32 |
+
or any(ord(char) < 32 or ord(char) == 127 for char in name)
|
| 33 |
+
or '"' in name
|
| 34 |
+
):
|
| 35 |
raise InputError("Invalid filename")
|
| 36 |
return name[:240]
|
| 37 |
|
|
|
|
| 47 |
if suffix and suffix not in MEDIA_EXTENSIONS:
|
| 48 |
raise InputError("Unsupported media file extension", details={"extension": suffix})
|
| 49 |
normalized_mime = mime_type.split(";", 1)[0].lower()
|
| 50 |
+
allowed = normalized_mime.startswith(("audio/", "video/", "image/", "text/")) or normalized_mime in {
|
| 51 |
+
"application/octet-stream", "application/x-subrip",
|
| 52 |
+
"application/vnd.apple.mpegurl", "application/x-mpegurl",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
if normalized_mime and not allowed:
|
| 55 |
raise InputError("Unsupported media MIME type", details={"mime_type": mime_type})
|
| 56 |
|
| 57 |
def validate_probe(self, metadata: dict[str, Any]) -> None:
|
| 58 |
+
streams = [*(metadata.get("video_streams") or []), *(metadata.get("audio_streams") or [])]
|
|
|
|
|
|
|
|
|
|
| 59 |
if not streams:
|
| 60 |
raise InputError("The input has no decodable video or audio stream")
|
| 61 |
for stream in streams:
|
app/templates/categories/motivation/templates.yaml
CHANGED
|
@@ -1,4 +1,42 @@
|
|
| 1 |
templates:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
- id: motivational_video
|
| 3 |
name: Motivational Video
|
| 4 |
category: motivation
|
|
|
|
| 1 |
templates:
|
| 2 |
+
- id: motivational_word_captions
|
| 3 |
+
name: Motivational Video — Word-by-Word Captions
|
| 4 |
+
category: motivation
|
| 5 |
+
description: Burn compact, individually timed narration words onto a vertical motivational video using Whisper word timestamps.
|
| 6 |
+
author: Enterprise Media API
|
| 7 |
+
version: 1
|
| 8 |
+
tags: [motivation, captions, word-by-word, vertical, narration]
|
| 9 |
+
estimated_runtime: medium
|
| 10 |
+
supported_inputs: [video, url]
|
| 11 |
+
supported_outputs: [mp4]
|
| 12 |
+
parameters:
|
| 13 |
+
model: {type: string, default: tiny, enum: [tiny, base, small]}
|
| 14 |
+
crf: {type: integer, default: 23, minimum: 0, maximum: 51}
|
| 15 |
+
font_size: {type: integer, default: 32, minimum: 16, maximum: 40}
|
| 16 |
+
caption_margin: {type: integer, default: 260, minimum: 40, maximum: 400}
|
| 17 |
+
pipeline:
|
| 18 |
+
- operation: resize
|
| 19 |
+
width: 1080
|
| 20 |
+
height: 1920
|
| 21 |
+
fit: cover
|
| 22 |
+
save_as: vertical_video
|
| 23 |
+
- operation: transcribe
|
| 24 |
+
inputs: [original]
|
| 25 |
+
model: "{{ model }}"
|
| 26 |
+
output_format: srt
|
| 27 |
+
word_timestamps: true
|
| 28 |
+
caption_mode: word
|
| 29 |
+
save_as: captions
|
| 30 |
+
- operation: burn_subtitles
|
| 31 |
+
inputs: [artifact:vertical_video, artifact:captions]
|
| 32 |
+
style: "FontName=Arial,FontSize={{ font_size }},Bold=1,PrimaryColour=&H0000FFFF,Outline=2,Shadow=0,Alignment=2,MarginV={{ caption_margin }}"
|
| 33 |
+
- {operation: normalize_video}
|
| 34 |
+
- {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4}
|
| 35 |
+
output: {format: mp4}
|
| 36 |
+
examples:
|
| 37 |
+
- input: {url: "https://example.com/motivation.mp4"}
|
| 38 |
+
parameters: {model: tiny, crf: 23, font_size: 32, caption_margin: 260}
|
| 39 |
+
|
| 40 |
- id: motivational_video
|
| 41 |
name: Motivational Video
|
| 42 |
category: motivation
|
app/templates/categories/podcast/templates.yaml
CHANGED
|
@@ -97,3 +97,31 @@ templates:
|
|
| 97 |
- {url: "https://example.com/waveform.png"}
|
| 98 |
- {url: "https://example.com/podcast.mp3"}
|
| 99 |
parameters: {duration: 60}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
- {url: "https://example.com/waveform.png"}
|
| 98 |
- {url: "https://example.com/podcast.mp3"}
|
| 99 |
parameters: {duration: 60}
|
| 100 |
+
|
| 101 |
+
- id: voiceover_music_ducking
|
| 102 |
+
name: Voiceover + Background Music Ducking
|
| 103 |
+
category: podcast
|
| 104 |
+
description: Mix background music beneath narration with automatic side-chain ducking and preserve the source video stream.
|
| 105 |
+
author: Enterprise Media API
|
| 106 |
+
version: 1
|
| 107 |
+
tags: [audio, ducking, voiceover, background-music, podcast, motivation]
|
| 108 |
+
estimated_runtime: medium
|
| 109 |
+
supported_inputs: [video, audio]
|
| 110 |
+
supported_outputs: [mp4]
|
| 111 |
+
parameters:
|
| 112 |
+
music_volume: {type: number, default: 0.22, minimum: 0, maximum: 2}
|
| 113 |
+
voice_volume: {type: number, default: 1, minimum: 0, maximum: 4}
|
| 114 |
+
threshold: {type: number, default: 0.03, minimum: 0.001, maximum: 1}
|
| 115 |
+
ratio: {type: number, default: 8, minimum: 1, maximum: 20}
|
| 116 |
+
attack: {type: number, default: 20, minimum: 1, maximum: 2000}
|
| 117 |
+
release: {type: number, default: 300, minimum: 10, maximum: 5000}
|
| 118 |
+
audio_bitrate: {type: string, default: "192k"}
|
| 119 |
+
pipeline:
|
| 120 |
+
- operation: duck_audio
|
| 121 |
+
inputs: [original, original:1]
|
| 122 |
+
output: {format: mp4}
|
| 123 |
+
examples:
|
| 124 |
+
- inputs:
|
| 125 |
+
- {url: "https://example.com/narrated-video.mp4"}
|
| 126 |
+
- {url: "https://example.com/background-music.mp3"}
|
| 127 |
+
parameters: {music_volume: 0.22, ratio: 8, attack: 20, release: 300}
|
app/templates/executor.py
CHANGED
|
@@ -37,6 +37,7 @@ from app.operations.extract_audio import (
|
|
| 37 |
set_volume,
|
| 38 |
)
|
| 39 |
from app.operations.merge import merge_audio, merge_video
|
|
|
|
| 40 |
from app.operations.resize import pad_video, resize_image, resize_video, scale_video
|
| 41 |
from app.operations.rotate import (
|
| 42 |
change_bitrate,
|
|
@@ -137,6 +138,7 @@ OPERATION_BINDINGS: Mapping[str, OperationBinding] = MappingProxyType(
|
|
| 137 |
"normalize_audio": _ffmpeg(normalize_audio),
|
| 138 |
"trim_audio": _ffmpeg(trim_audio),
|
| 139 |
"merge_audio": _ffmpeg(merge_audio, input_mode="all"),
|
|
|
|
| 140 |
"concat_audio": _ffmpeg(concat_audio, input_mode="all"),
|
| 141 |
"fade_audio": _ffmpeg(fade_audio),
|
| 142 |
"volume": _ffmpeg(set_volume),
|
|
@@ -270,11 +272,12 @@ class TemplateExecutor:
|
|
| 270 |
if not operations:
|
| 271 |
raise TemplateExecutionError("All template pipeline operations were disabled")
|
| 272 |
self._validate_output(prepared, current)
|
|
|
|
| 273 |
result = OperationResult(
|
| 274 |
path=current.temp_path,
|
| 275 |
filename=prepared.output.filename or current.filename,
|
| 276 |
mime_type=current.mime_type,
|
| 277 |
-
metadata=current.metadata,
|
| 278 |
)
|
| 279 |
response = await self.processor.finish_result(
|
| 280 |
resolved,
|
|
@@ -409,6 +412,14 @@ class TemplateExecutor:
|
|
| 409 |
|
| 410 |
@staticmethod
|
| 411 |
def _validate_output(prepared: PreparedTemplate, media: InputMedia) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
if prepared.output.filename:
|
| 413 |
filename = prepared.output.filename
|
| 414 |
if Path(filename).name != filename or len(filename) > 255:
|
|
@@ -424,6 +435,40 @@ class TemplateExecutor:
|
|
| 424 |
details={"expected": expected, "actual": actual},
|
| 425 |
)
|
| 426 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
@staticmethod
|
| 428 |
def _log_data(
|
| 429 |
prepared: PreparedTemplate | None,
|
|
|
|
| 37 |
set_volume,
|
| 38 |
)
|
| 39 |
from app.operations.merge import merge_audio, merge_video
|
| 40 |
+
from app.operations.mix_audio import duck_background_music
|
| 41 |
from app.operations.resize import pad_video, resize_image, resize_video, scale_video
|
| 42 |
from app.operations.rotate import (
|
| 43 |
change_bitrate,
|
|
|
|
| 138 |
"normalize_audio": _ffmpeg(normalize_audio),
|
| 139 |
"trim_audio": _ffmpeg(trim_audio),
|
| 140 |
"merge_audio": _ffmpeg(merge_audio, input_mode="all"),
|
| 141 |
+
"duck_audio": _ffmpeg(duck_background_music, input_mode="all"),
|
| 142 |
"concat_audio": _ffmpeg(concat_audio, input_mode="all"),
|
| 143 |
"fade_audio": _ffmpeg(fade_audio),
|
| 144 |
"volume": _ffmpeg(set_volume),
|
|
|
|
| 272 |
if not operations:
|
| 273 |
raise TemplateExecutionError("All template pipeline operations were disabled")
|
| 274 |
self._validate_output(prepared, current)
|
| 275 |
+
output_validation = self._output_validation(current)
|
| 276 |
result = OperationResult(
|
| 277 |
path=current.temp_path,
|
| 278 |
filename=prepared.output.filename or current.filename,
|
| 279 |
mime_type=current.mime_type,
|
| 280 |
+
metadata={**current.metadata, "output_validation": output_validation},
|
| 281 |
)
|
| 282 |
response = await self.processor.finish_result(
|
| 283 |
resolved,
|
|
|
|
| 412 |
|
| 413 |
@staticmethod
|
| 414 |
def _validate_output(prepared: PreparedTemplate, media: InputMedia) -> None:
|
| 415 |
+
if not media.temp_path.is_file():
|
| 416 |
+
raise TemplateExecutionError("Template output artifact is missing")
|
| 417 |
+
try:
|
| 418 |
+
size = media.temp_path.stat().st_size
|
| 419 |
+
except OSError as exc:
|
| 420 |
+
raise TemplateExecutionError("Unable to inspect template output artifact") from exc
|
| 421 |
+
if size <= 0:
|
| 422 |
+
raise TemplateExecutionError("Template output artifact is empty")
|
| 423 |
if prepared.output.filename:
|
| 424 |
filename = prepared.output.filename
|
| 425 |
if Path(filename).name != filename or len(filename) > 255:
|
|
|
|
| 435 |
details={"expected": expected, "actual": actual},
|
| 436 |
)
|
| 437 |
|
| 438 |
+
@staticmethod
|
| 439 |
+
def _output_validation(media: InputMedia) -> dict[str, object]:
|
| 440 |
+
"""Summarize checks performed on the final artifact for automation clients."""
|
| 441 |
+
metadata = media.metadata
|
| 442 |
+
size = media.temp_path.stat().st_size
|
| 443 |
+
video_streams = metadata.get("video_streams")
|
| 444 |
+
audio_streams = metadata.get("audio_streams")
|
| 445 |
+
if media.mime_type.startswith("video/") and video_streams is not None and not video_streams:
|
| 446 |
+
raise TemplateExecutionError(
|
| 447 |
+
"Template output is declared as video but contains no video stream"
|
| 448 |
+
)
|
| 449 |
+
if media.mime_type.startswith("audio/") and audio_streams is not None and not audio_streams:
|
| 450 |
+
raise TemplateExecutionError(
|
| 451 |
+
"Template output is declared as audio but contains no audio stream"
|
| 452 |
+
)
|
| 453 |
+
duration = metadata.get("duration")
|
| 454 |
+
if media.mime_type.startswith(("video/", "audio/")) and duration is not None:
|
| 455 |
+
try:
|
| 456 |
+
valid_duration = float(duration) > 0
|
| 457 |
+
except (TypeError, ValueError):
|
| 458 |
+
valid_duration = False
|
| 459 |
+
if not valid_duration:
|
| 460 |
+
raise TemplateExecutionError("Template output has an invalid media duration")
|
| 461 |
+
return {
|
| 462 |
+
"valid": True,
|
| 463 |
+
"size_bytes": size,
|
| 464 |
+
"mime_type": media.mime_type,
|
| 465 |
+
"container": metadata.get("container"),
|
| 466 |
+
"duration_seconds": duration,
|
| 467 |
+
"video_stream_count": len(video_streams) if isinstance(video_streams, list) else None,
|
| 468 |
+
"audio_stream_count": len(audio_streams) if isinstance(audio_streams, list) else None,
|
| 469 |
+
"resolution": metadata.get("resolution"),
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
@staticmethod
|
| 473 |
def _log_data(
|
| 474 |
prepared: PreparedTemplate | None,
|
docs/api-wide-output-validation.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API-wide media output validation
|
| 2 |
+
|
| 3 |
+
Every file-producing operation that reaches `MediaProcessor.finish_result` now validates its final artifact before publication. This covers the shared execution path used by the standalone video, audio, image, conversion, and template APIs.
|
| 4 |
+
|
| 5 |
+
## Validation sequence
|
| 6 |
+
|
| 7 |
+
1. Verify that the output path exists and is a regular file.
|
| 8 |
+
2. Reject zero-byte artifacts.
|
| 9 |
+
3. For audio, video, and image MIME types, run FFprobe on the actual output file.
|
| 10 |
+
4. Require a video stream for video outputs, an audio stream for audio outputs, and at least one decodable stream for image outputs.
|
| 11 |
+
5. Apply the shared media-probe constraints and require a finite, positive duration for audio/video outputs.
|
| 12 |
+
6. Publish only after validation succeeds.
|
| 13 |
+
|
| 14 |
+
A failed check raises a processing error; the file is not published as a successful response.
|
| 15 |
+
|
| 16 |
+
## Response metadata
|
| 17 |
+
|
| 18 |
+
Successful file responses include `metadata.output_validation`:
|
| 19 |
+
|
| 20 |
+
- `valid`, `file_artifact`, `size_bytes`, and `mime_type`
|
| 21 |
+
- `probed` indicates whether FFprobe ran
|
| 22 |
+
- `container`, `duration_seconds`, `video_stream_count`, `audio_stream_count`, and `resolution` are populated when probing is applicable and successful
|
| 23 |
+
|
| 24 |
+
Text-like outputs such as SRT/VTT and JSON artifacts are checked for file existence and non-zero size, but are not sent through FFprobe. Responses without a file artifact are not subject to file-output validation.
|
| 25 |
+
|
| 26 |
+
## Operational note
|
| 27 |
+
|
| 28 |
+
FFprobe validation adds work after each render. The output must be fully readable by FFprobe before the API publishes it. Automated tests and a live render should be run before merging this change request.
|
docs/artifact-download-hardening.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Artifact download hardening
|
| 2 |
+
|
| 3 |
+
MediaRouter download paths are treated as untrusted locators, not permissions.
|
| 4 |
+
|
| 5 |
+
The download boundary now:
|
| 6 |
+
- validates request IDs as UUIDs and filenames as single safe path components;
|
| 7 |
+
- rejects slash, backslash, control characters, quotes, traversal names, and empty filenames;
|
| 8 |
+
- rejects symlinked request directories and symlinked artifact files before resolving a published path;
|
| 9 |
+
- preserves workspace ownership and content-digest verification for authenticated direct downloads;
|
| 10 |
+
- verifies signed URLs with HMAC, rejects expired links, and caps requested link lifetime at `MEDIA_SIGNED_URL_TTL_SECONDS`;
|
| 11 |
+
- sends `Cache-Control: private, no-store` and `X-Content-Type-Options: nosniff` on direct and signed download responses.
|
| 12 |
+
|
| 13 |
+
Signed URLs remain bearer capabilities: anyone who receives a valid URL can use it until it expires. Do not put signed URLs in public logs, analytics, or long-lived messages.
|
| 14 |
+
|
| 15 |
+
## Verification status
|
| 16 |
+
|
| 17 |
+
Regression tests were added for traversal and symlink rejection, expired signatures, and TTL limits. They have not been executed in the remote environment; previous hosted test execution was blocked by a payment-required response. Treat this as source-level hardening pending CI and deployed integration tests.
|
docs/input-ingestion-security.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Input-ingestion security hardening
|
| 2 |
+
|
| 3 |
+
The input pipeline now applies these protections at the resolver boundary:
|
| 4 |
+
|
| 5 |
+
- URL fetching accepts only HTTP(S), rejects URL credentials and malformed ports, resolves using the scheme's correct default port, and rejects hostnames with any non-global DNS answer when private URLs are disabled.
|
| 6 |
+
- Redirects are handled manually, with each destination revalidated before the next request.
|
| 7 |
+
- HTTPX environment proxy settings are not inherited for remote media fetches, avoiding an ambient proxy changing the destination semantics.
|
| 8 |
+
- Remote downloads remain streamed to disk with a hard byte limit, including responses that omit or misstate Content-Length. Partial files are removed on download/validation failures.
|
| 9 |
+
- Client filenames are normalized across POSIX and Windows separators and reject control characters and quote characters that can corrupt response headers.
|
| 10 |
+
|
| 11 |
+
## Deployment notes and remaining limits
|
| 12 |
+
|
| 13 |
+
- `ALLOW_PRIVATE_URLS=true` intentionally disables the private-address block; only enable it in a trusted, isolated environment.
|
| 14 |
+
- DNS validation and the HTTP client's subsequent connection resolution are separate operations. For hostile URL-fetching environments, production-grade SSRF protection should additionally pin the connection to a validated IP while preserving TLS hostname verification, or enforce egress restrictions at the network layer.
|
| 15 |
+
- Multipart parsing can spool uploads before application-level per-file limits are applied. Enforce request-body limits at the reverse proxy/ASGI boundary as well as in the resolver.
|
| 16 |
+
- Source-level regression tests were added but have not been run in the hosted environment. Deployment and live SSRF tests remain unverified.
|
docs/template-output-validation.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Template final-output validation
|
| 2 |
+
|
| 3 |
+
Template runs validate the final artifact before publishing it to the media download layer.
|
| 4 |
+
|
| 5 |
+
## Checks
|
| 6 |
+
|
| 7 |
+
- The final artifact exists and has a non-zero file size.
|
| 8 |
+
- The artifact's extension matches the template's declared output format (except `source`, which preserves the input format).
|
| 9 |
+
- Probeable media is passed through the existing FFprobe validation path. The probe rejects outputs without decodable audio/video streams.
|
| 10 |
+
- When stream metadata is available, the final check confirms that audio and video MIME types have their corresponding stream and that reported audio/video duration is positive.
|
| 11 |
+
|
| 12 |
+
A failed check raises a template execution error, so the output is not published as a successful result.
|
| 13 |
+
|
| 14 |
+
## Response metadata
|
| 15 |
+
|
| 16 |
+
Successful template responses include an `output_validation` object in `metadata`, for example:
|
| 17 |
+
|
| 18 |
+
```json
|
| 19 |
+
{
|
| 20 |
+
"output_validation": {
|
| 21 |
+
"valid": true,
|
| 22 |
+
"size_bytes": 4821093,
|
| 23 |
+
"mime_type": "video/mp4",
|
| 24 |
+
"container": "mov,mp4,m4a,3gp,3g2,mj2",
|
| 25 |
+
"duration_seconds": 31.25,
|
| 26 |
+
"video_stream_count": 1,
|
| 27 |
+
"audio_stream_count": 1,
|
| 28 |
+
"resolution": {
|
| 29 |
+
"width": 1080,
|
| 30 |
+
"height": 1920
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Stream counts, container, duration, and resolution can be `null` when the output type is not probeable or a metadata field is unavailable. The file size and MIME type are always reported for a valid file artifact.
|
| 37 |
+
|
| 38 |
+
This validation is applied to template pipeline outputs; it does not imply that every standalone media endpoint has the same response metadata.
|
docs/voiceover-music-ducking.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Voiceover + background music ducking
|
| 2 |
+
|
| 3 |
+
The `voiceover_music_ducking` template mixes a video containing narration with a separate background-music file. FFmpeg side-chain compression lowers the music while narration is active, then mixes the narration and ducked music into the output. The source video stream is copied without re-encoding.
|
| 4 |
+
|
| 5 |
+
## Endpoint
|
| 6 |
+
|
| 7 |
+
`POST https://basyx-mediarouter.hf.space/v1/templates/run`
|
| 8 |
+
|
| 9 |
+
## Request example
|
| 10 |
+
|
| 11 |
+
```json
|
| 12 |
+
{
|
| 13 |
+
"template": "voiceover_music_ducking",
|
| 14 |
+
"inputs": [
|
| 15 |
+
{
|
| 16 |
+
"url": "https://example.com/narrated-video.mp4"
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"url": "https://example.com/background-music.mp3"
|
| 20 |
+
}
|
| 21 |
+
],
|
| 22 |
+
"parameters": {
|
| 23 |
+
"music_volume": 0.22,
|
| 24 |
+
"voice_volume": 1,
|
| 25 |
+
"threshold": 0.03,
|
| 26 |
+
"ratio": 8,
|
| 27 |
+
"attack": 20,
|
| 28 |
+
"release": 300,
|
| 29 |
+
"audio_bitrate": "192k"
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Use the standard MediaRouter input contract for multiple media inputs; if your client uses a nested `input` object, supply the equivalent two-item input collection supported by the shared resolver.
|
| 35 |
+
|
| 36 |
+
## Parameters
|
| 37 |
+
|
| 38 |
+
| Parameter | Default | Range | Purpose |
|
| 39 |
+
|---|---:|---:|---|
|
| 40 |
+
| `music_volume` | 0.22 | 0–2 | Linear gain applied to the music before ducking |
|
| 41 |
+
| `voice_volume` | 1 | 0–4 | Linear gain applied to narration |
|
| 42 |
+
| `threshold` | 0.03 | 0.001–1 | Side-chain threshold; lower values trigger ducking more readily |
|
| 43 |
+
| `ratio` | 8 | 1–20 | Compression ratio for the music |
|
| 44 |
+
| `attack` | 20 ms | 1–2000 ms | How quickly music is reduced after narration starts |
|
| 45 |
+
| `release` | 300 ms | 10–5000 ms | How quickly music returns after narration pauses |
|
| 46 |
+
| `audio_bitrate` | `192k` | Supported AAC bitrates | Output audio bitrate |
|
| 47 |
+
|
| 48 |
+
The source video must contain an audio stream with the narration, and its duration must be available from probing. This template does not generate narration or music. Listen to the rendered output and adjust music gain and compression settings for the particular mix.
|
| 49 |
+
|
| 50 |
+
## Template discovery
|
| 51 |
+
|
| 52 |
+
- `GET /v1/templates/voiceover_music_ducking`
|
| 53 |
+
- `GET /v1/templates?category=podcast`
|
docs/word-by-word-captions.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Word-by-word motivational captions
|
| 2 |
+
|
| 3 |
+
MediaRouter provides the `motivational_word_captions` template for producing a vertical MP4 with one timed caption cue per recognized word.
|
| 4 |
+
|
| 5 |
+
## Endpoint
|
| 6 |
+
|
| 7 |
+
`POST https://basyx-mediarouter.hf.space/v1/templates/run`
|
| 8 |
+
|
| 9 |
+
Use the template ID `motivational_word_captions`. The template resizes the source to 1080×1920, transcribes the original media with Whisper word timestamps, burns the resulting SRT captions onto the resized video, normalizes the output, and encodes an MP4.
|
| 10 |
+
|
| 11 |
+
## Request example
|
| 12 |
+
|
| 13 |
+
Provide a publicly reachable video URL in the standard template input contract:
|
| 14 |
+
|
| 15 |
+
```json
|
| 16 |
+
{
|
| 17 |
+
"template": "motivational_word_captions",
|
| 18 |
+
"input": {
|
| 19 |
+
"url": "https://example.com/your-video.mp4"
|
| 20 |
+
},
|
| 21 |
+
"parameters": {
|
| 22 |
+
"model": "tiny",
|
| 23 |
+
"crf": 23,
|
| 24 |
+
"font_size": 32,
|
| 25 |
+
"caption_margin": 260
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
You can also submit media through the other input transports supported by MediaRouter's shared input resolver.
|
| 31 |
+
|
| 32 |
+
## Parameters
|
| 33 |
+
|
| 34 |
+
| Parameter | Default | Allowed range / values | Purpose |
|
| 35 |
+
|---|---:|---|---|
|
| 36 |
+
| `model` | `tiny` | `tiny`, `base`, `small` | Whisper model size; larger models generally use more compute |
|
| 37 |
+
| `crf` | `23` | 0–51 | H.264 quality/size trade-off; lower values preserve more quality and increase file size |
|
| 38 |
+
| `font_size` | `32` | 16–40 | Caption font size for the 1080×1920 output |
|
| 39 |
+
| `caption_margin` | `260` | 40–400 | Vertical margin from the bottom edge; increase it to move captions higher |
|
| 40 |
+
|
| 41 |
+
Captions are individually timed from Whisper's word timestamps. Timing is model-estimated, not guaranteed to match every pronunciation perfectly; review the final render before publishing, especially for names, accents, music-heavy audio, or overlapping speech.
|
| 42 |
+
|
| 43 |
+
## Inspect the template
|
| 44 |
+
|
| 45 |
+
- `GET /v1/templates/motivational_word_captions`
|
| 46 |
+
- `GET /v1/templates?category=motivation`
|
| 47 |
+
|
| 48 |
+
This template processes an already assembled video. It does not generate scenes, narration, or background music.
|
tests/test_artifact_download_security.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from app.core.exceptions import NotFoundError
|
| 9 |
+
from app.services.cleanup import CleanupService
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_resolve_download_returns_only_file_in_request_directory(settings) -> None:
|
| 13 |
+
cleanup = CleanupService(settings)
|
| 14 |
+
request_id = str(uuid4())
|
| 15 |
+
request_dir = settings.output_dir / request_id
|
| 16 |
+
request_dir.mkdir(parents=True)
|
| 17 |
+
artifact = request_dir / "clip.mp4"
|
| 18 |
+
artifact.write_bytes(b"media")
|
| 19 |
+
|
| 20 |
+
assert cleanup.resolve_download(request_id, "clip.mp4") == artifact.resolve()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.mark.parametrize("filename", ["../secret.txt", "sub/file.mp4", "sub\\file.mp4", 'bad"name.mp4', "bad\nname.mp4", ""])
|
| 24 |
+
def test_resolve_download_rejects_unsafe_filenames(settings, filename: str) -> None:
|
| 25 |
+
cleanup = CleanupService(settings)
|
| 26 |
+
with pytest.raises(NotFoundError):
|
| 27 |
+
cleanup.resolve_download(str(uuid4()), filename)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_resolve_download_rejects_symlinked_artifact(settings, tmp_path: Path) -> None:
|
| 31 |
+
cleanup = CleanupService(settings)
|
| 32 |
+
request_id = str(uuid4())
|
| 33 |
+
request_dir = settings.output_dir / request_id
|
| 34 |
+
request_dir.mkdir(parents=True)
|
| 35 |
+
outside = tmp_path / "outside.mp4"
|
| 36 |
+
outside.write_bytes(b"not published")
|
| 37 |
+
link = request_dir / "clip.mp4"
|
| 38 |
+
try:
|
| 39 |
+
link.symlink_to(outside)
|
| 40 |
+
except (OSError, NotImplementedError):
|
| 41 |
+
pytest.skip("Symlink creation is unavailable on this platform")
|
| 42 |
+
|
| 43 |
+
with pytest.raises(NotFoundError):
|
| 44 |
+
cleanup.resolve_download(request_id, "clip.mp4")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_resolve_download_rejects_symlinked_request_directory(settings, tmp_path: Path) -> None:
|
| 48 |
+
cleanup = CleanupService(settings)
|
| 49 |
+
request_id = str(uuid4())
|
| 50 |
+
outside = tmp_path / "outside"
|
| 51 |
+
outside.mkdir()
|
| 52 |
+
(outside / "clip.mp4").write_bytes(b"not published")
|
| 53 |
+
settings.output_dir.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
try:
|
| 55 |
+
(settings.output_dir / request_id).symlink_to(outside, target_is_directory=True)
|
| 56 |
+
except (OSError, NotImplementedError):
|
| 57 |
+
pytest.skip("Symlink creation is unavailable on this platform")
|
| 58 |
+
|
| 59 |
+
with pytest.raises(NotFoundError):
|
| 60 |
+
cleanup.resolve_download(request_id, "clip.mp4")
|
tests/test_downloader.py
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from unittest.mock import AsyncMock
|
| 2 |
|
|
|
|
| 3 |
import respx
|
| 4 |
from httpx import Response
|
| 5 |
|
|
|
|
| 6 |
from app.services.downloader import Downloader
|
| 7 |
from app.services.validator import MediaValidator
|
| 8 |
|
|
@@ -18,3 +23,52 @@ async def test_url_download_streams_to_disk(settings, tmp_path) -> None:
|
|
| 18 |
path, mime_type = await downloader.download(url, tmp_path)
|
| 19 |
assert path.read_bytes() == b"ID3data"
|
| 20 |
assert mime_type == "audio/mpeg"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import socket
|
| 4 |
from unittest.mock import AsyncMock
|
| 5 |
|
| 6 |
+
import pytest
|
| 7 |
import respx
|
| 8 |
from httpx import Response
|
| 9 |
|
| 10 |
+
from app.core.exceptions import InputError
|
| 11 |
from app.services.downloader import Downloader
|
| 12 |
from app.services.validator import MediaValidator
|
| 13 |
|
|
|
|
| 23 |
path, mime_type = await downloader.download(url, tmp_path)
|
| 24 |
assert path.read_bytes() == b"ID3data"
|
| 25 |
assert mime_type == "audio/mpeg"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def test_validate_url_rejects_non_global_dns_answer(settings, monkeypatch) -> None:
|
| 29 |
+
settings.allow_private_urls = False
|
| 30 |
+
downloader = Downloader(settings, MediaValidator(settings))
|
| 31 |
+
|
| 32 |
+
def private_answer(*args, **kwargs):
|
| 33 |
+
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443))]
|
| 34 |
+
|
| 35 |
+
monkeypatch.setattr(socket, "getaddrinfo", private_answer)
|
| 36 |
+
with pytest.raises(InputError, match="Private, loopback"):
|
| 37 |
+
await downloader.validate_url("https://example.test/media.mp4")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def test_validate_url_rejects_mixed_public_and_private_dns(settings, monkeypatch) -> None:
|
| 41 |
+
settings.allow_private_urls = False
|
| 42 |
+
downloader = Downloader(settings, MediaValidator(settings))
|
| 43 |
+
|
| 44 |
+
def mixed_answers(*args, **kwargs):
|
| 45 |
+
return [
|
| 46 |
+
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)),
|
| 47 |
+
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.4", 443)),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
monkeypatch.setattr(socket, "getaddrinfo", mixed_answers)
|
| 51 |
+
with pytest.raises(InputError, match="Private, loopback"):
|
| 52 |
+
await downloader.validate_url("https://example.test/media.mp4")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@pytest.mark.parametrize("url", [
|
| 56 |
+
"file:///etc/passwd",
|
| 57 |
+
"https://user:password@example.test/media.mp4",
|
| 58 |
+
"https://example.test:invalid/media.mp4",
|
| 59 |
+
"https://example.test:99999/media.mp4",
|
| 60 |
+
])
|
| 61 |
+
async def test_validate_url_rejects_invalid_or_credentialed_urls(settings, url: str) -> None:
|
| 62 |
+
settings.allow_private_urls = False
|
| 63 |
+
downloader = Downloader(settings, MediaValidator(settings))
|
| 64 |
+
with pytest.raises(InputError):
|
| 65 |
+
await downloader.validate_url(url)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_safe_filename_normalizes_windows_paths_and_rejects_header_injection(settings) -> None:
|
| 69 |
+
validator = MediaValidator(settings)
|
| 70 |
+
assert validator.safe_filename(r"C:\\temp\\clip.mp4") == "clip.mp4"
|
| 71 |
+
with pytest.raises(InputError):
|
| 72 |
+
validator.safe_filename("clip.mp4\r\nX-Evil: yes")
|
| 73 |
+
with pytest.raises(InputError):
|
| 74 |
+
validator.safe_filename('clip".mp4')
|
tests/test_media_url_signer.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
from app.services.media_url_signer import MediaURLSigner
|
| 4 |
|
| 5 |
|
|
@@ -9,18 +12,36 @@ def test_signed_media_url_round_trip(settings) -> None:
|
|
| 9 |
signer = MediaURLSigner(settings)
|
| 10 |
url = signer.sign("123e4567-e89b-12d3-a456-426614174000", "clip.mp4", ttl=3600)
|
| 11 |
assert url is not None
|
| 12 |
-
from urllib.parse import parse_qs, urlparse
|
| 13 |
parsed = urlparse(url)
|
| 14 |
params = parse_qs(parsed.query)
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import time
|
| 4 |
+
from urllib.parse import parse_qs, urlparse
|
| 5 |
+
|
| 6 |
from app.services.media_url_signer import MediaURLSigner
|
| 7 |
|
| 8 |
|
|
|
|
| 12 |
signer = MediaURLSigner(settings)
|
| 13 |
url = signer.sign("123e4567-e89b-12d3-a456-426614174000", "clip.mp4", ttl=3600)
|
| 14 |
assert url is not None
|
|
|
|
| 15 |
parsed = urlparse(url)
|
| 16 |
params = parse_qs(parsed.query)
|
| 17 |
+
expires = int(params["expires"][0])
|
| 18 |
+
signature = params["signature"][0]
|
| 19 |
+
assert signer.verify("123e4567-e89b-12d3-a456-426614174000", "clip.mp4", expires, signature)
|
| 20 |
+
assert not signer.verify("123e4567-e89b-12d3-a456-426614174000", "other.mp4", expires, signature)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_signed_media_url_rejects_expired_and_overlong_expiry(settings) -> None:
|
| 24 |
+
settings.media_signing_secret = "a" * 32
|
| 25 |
+
signer = MediaURLSigner(settings)
|
| 26 |
+
assert not signer.verify("request", "clip.mp4", int(time.time()) - 1, "x" * 43)
|
| 27 |
+
|
| 28 |
+
expiry = int(time.time()) + settings.media_signed_url_ttl_seconds + 120
|
| 29 |
+
import base64
|
| 30 |
+
import hashlib
|
| 31 |
+
import hmac
|
| 32 |
+
|
| 33 |
+
message = f"request\nclip.mp4\n{expiry}".encode()
|
| 34 |
+
signature = base64.urlsafe_b64encode(
|
| 35 |
+
hmac.new(b"a" * 32, message, hashlib.sha256).digest()
|
| 36 |
+
).decode().rstrip("=")
|
| 37 |
+
assert not signer.verify("request", "clip.mp4", expiry, signature)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_signer_caps_requested_ttl_to_configured_maximum(settings) -> None:
|
| 41 |
+
settings.media_signing_secret = "a" * 32
|
| 42 |
+
settings.base_url = "https://media.example"
|
| 43 |
+
signer = MediaURLSigner(settings)
|
| 44 |
+
url = signer.sign("request", "clip.mp4", ttl=settings.media_signed_url_ttl_seconds * 10)
|
| 45 |
+
assert url is not None
|
| 46 |
+
expiry = int(parse_qs(urlparse(url).query)["expires"][0])
|
| 47 |
+
assert expiry <= int(time.time()) + settings.media_signed_url_ttl_seconds
|
tests/test_mix_audio.py
CHANGED
|
@@ -95,3 +95,36 @@ async def test_add_audio_rejects_invalid_bitrate(tmp_path: Path) -> None:
|
|
| 95 |
sfx = _media(tmp_path / "one.wav", "one.wav", "audio/wav")
|
| 96 |
with pytest.raises(InputError, match="supported AAC bitrate"):
|
| 97 |
await add_audio(FakeFFmpeg(), [video, sfx], {"audio_bitrate": "999k"}, tmp_path / "out")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
sfx = _media(tmp_path / "one.wav", "one.wav", "audio/wav")
|
| 96 |
with pytest.raises(InputError, match="supported AAC bitrate"):
|
| 97 |
await add_audio(FakeFFmpeg(), [video, sfx], {"audio_bitrate": "999k"}, tmp_path / "out")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@pytest.mark.asyncio
|
| 101 |
+
async def test_duck_background_music_uses_voice_sidechain_and_preserves_video(tmp_path: Path) -> None:
|
| 102 |
+
from app.operations.mix_audio import duck_background_music
|
| 103 |
+
|
| 104 |
+
video = _media(
|
| 105 |
+
tmp_path / "voice.mp4", "voice.mp4", "video/mp4", duration=18.0,
|
| 106 |
+
metadata={"audio_streams": [{"codec": "aac"}]},
|
| 107 |
+
)
|
| 108 |
+
music = _media(tmp_path / "music.mp3", "music.mp3", "audio/mpeg")
|
| 109 |
+
ffmpeg = FakeFFmpeg()
|
| 110 |
+
result = await duck_background_music(
|
| 111 |
+
ffmpeg, [video, music], {"music_volume": 0.2, "ratio": 8, "attack": 20, "release": 300},
|
| 112 |
+
tmp_path / "out",
|
| 113 |
+
)
|
| 114 |
+
command = [str(value) for value in ffmpeg.args]
|
| 115 |
+
filt = command[command.index("-filter_complex") + 1]
|
| 116 |
+
assert "[music][voice]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=300[ducked]" in filt
|
| 117 |
+
assert "[voice][ducked]amix=inputs=2:duration=first:normalize=0[mixed]" in filt
|
| 118 |
+
assert command[command.index("-c:v") + 1] == "copy"
|
| 119 |
+
assert result.metadata["mix_mode"] == "sidechain_ducking"
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@pytest.mark.asyncio
|
| 123 |
+
async def test_duck_background_music_rejects_silent_video(tmp_path: Path) -> None:
|
| 124 |
+
from app.operations.mix_audio import duck_background_music
|
| 125 |
+
|
| 126 |
+
video = _media(tmp_path / "silent.mp4", "silent.mp4", "video/mp4", duration=8.0,
|
| 127 |
+
metadata={"audio_streams": []})
|
| 128 |
+
music = _media(tmp_path / "music.mp3", "music.mp3", "audio/mpeg")
|
| 129 |
+
with pytest.raises(InputError, match="requires narration audio"):
|
| 130 |
+
await duck_background_music(FakeFFmpeg(), [video, music], {}, tmp_path / "out")
|
tests/test_output_validation.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app.container import build_container
|
| 8 |
+
from app.core.exceptions import ProcessingError
|
| 9 |
+
from app.models.media import OperationResult
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.mark.asyncio
|
| 13 |
+
async def test_output_validation_probes_audio_artifact(settings, tmp_path: Path, monkeypatch) -> None:
|
| 14 |
+
container = build_container(settings)
|
| 15 |
+
output = tmp_path / "final.mp3"
|
| 16 |
+
output.write_bytes(b"audio-placeholder")
|
| 17 |
+
|
| 18 |
+
async def fake_probe(path: Path):
|
| 19 |
+
assert path == output
|
| 20 |
+
return {
|
| 21 |
+
"duration": 12.5,
|
| 22 |
+
"container": "mp3",
|
| 23 |
+
"video_streams": [],
|
| 24 |
+
"audio_streams": [{"codec": "mp3"}],
|
| 25 |
+
"resolution": None,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
monkeypatch.setattr(container.processor.ffprobe, "probe", fake_probe)
|
| 29 |
+
report = await container.processor.validate_output_result(
|
| 30 |
+
OperationResult(path=output, filename=output.name, mime_type="audio/mpeg")
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
assert report["valid"] is True
|
| 34 |
+
assert report["probed"] is True
|
| 35 |
+
assert report["size_bytes"] == output.stat().st_size
|
| 36 |
+
assert report["audio_stream_count"] == 1
|
| 37 |
+
assert report["duration_seconds"] == 12.5
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.mark.asyncio
|
| 41 |
+
async def test_output_validation_rejects_audio_without_audio_stream(settings, tmp_path: Path, monkeypatch) -> None:
|
| 42 |
+
container = build_container(settings)
|
| 43 |
+
output = tmp_path / "broken.mp3"
|
| 44 |
+
output.write_bytes(b"not-real-audio")
|
| 45 |
+
|
| 46 |
+
async def fake_probe(path: Path):
|
| 47 |
+
return {
|
| 48 |
+
"duration": 1.0,
|
| 49 |
+
"container": "mp3",
|
| 50 |
+
"video_streams": [],
|
| 51 |
+
"audio_streams": [],
|
| 52 |
+
"resolution": None,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
monkeypatch.setattr(container.processor.ffprobe, "probe", fake_probe)
|
| 56 |
+
with pytest.raises(ProcessingError, match="no audio stream"):
|
| 57 |
+
await container.processor.validate_output_result(
|
| 58 |
+
OperationResult(path=output, filename=output.name, mime_type="audio/mpeg")
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@pytest.mark.asyncio
|
| 63 |
+
async def test_output_validation_rejects_empty_artifact(settings, tmp_path: Path) -> None:
|
| 64 |
+
container = build_container(settings)
|
| 65 |
+
output = tmp_path / "empty.mp4"
|
| 66 |
+
output.write_bytes(b"")
|
| 67 |
+
|
| 68 |
+
with pytest.raises(ProcessingError, match="empty"):
|
| 69 |
+
await container.processor.validate_output_result(
|
| 70 |
+
OperationResult(path=output, filename=output.name, mime_type="video/mp4")
|
| 71 |
+
)
|
tests/test_templates.py
CHANGED
|
@@ -7,14 +7,25 @@ import pytest
|
|
| 7 |
from fastapi.testclient import TestClient
|
| 8 |
|
| 9 |
from app.container import build_container
|
| 10 |
-
from app.core.exceptions import TemplateValidationError
|
| 11 |
from app.models.media import InputMedia, MediaSource, ResolvedRequest
|
| 12 |
-
from app.templates.executor import OPERATION_BINDINGS
|
| 13 |
from app.templates.loader import TemplateLoader
|
| 14 |
from app.templates.registry import TemplateRegistry
|
| 15 |
from app.templates.validator import TemplateValidator
|
| 16 |
from main import create_app
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
EXPECTED_CATEGORIES = {
|
| 19 |
"branding",
|
| 20 |
"conversion",
|
|
@@ -32,7 +43,7 @@ EXPECTED_CATEGORIES = {
|
|
| 32 |
def test_builtin_templates_are_loaded_dynamically(settings) -> None:
|
| 33 |
registry = build_container(settings).template_registry
|
| 34 |
|
| 35 |
-
assert registry.count ==
|
| 36 |
assert set(registry.categories()) == EXPECTED_CATEGORIES
|
| 37 |
assert registry.get("instagram_reel").version == 1
|
| 38 |
assert registry.get("instagram_reel@latest").version == 1
|
|
@@ -52,6 +63,22 @@ def test_parameter_substitution_preserves_declared_types(settings) -> None:
|
|
| 52 |
assert isinstance(compress_step.parameters["crf"], int)
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def test_template_registry_keeps_old_versions(tmp_path: Path) -> None:
|
| 56 |
root = tmp_path / "templates"
|
| 57 |
root.mkdir()
|
|
@@ -192,6 +219,7 @@ async def test_template_executor_calls_existing_operation(settings, tmp_path, mo
|
|
| 192 |
output.write_bytes(b"ID3-template-output")
|
| 193 |
|
| 194 |
monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
|
|
|
|
| 195 |
monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
|
| 196 |
resolved = ResolvedRequest(
|
| 197 |
request_id="5fdbe750-4cb7-4f87-aa5c-3df50c3a6629",
|
|
@@ -238,6 +266,7 @@ def test_template_rest_endpoints_and_nested_input(settings, monkeypatch) -> None
|
|
| 238 |
output.write_bytes(b"ID3-rest-template")
|
| 239 |
|
| 240 |
monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
|
|
|
|
| 241 |
monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
|
| 242 |
|
| 243 |
with TestClient(application) as client:
|
|
@@ -258,8 +287,62 @@ def test_template_rest_endpoints_and_nested_input(settings, monkeypatch) -> None
|
|
| 258 |
)
|
| 259 |
|
| 260 |
assert listing.status_code == 200
|
| 261 |
-
assert listing.json()["metadata"]["count"] ==
|
| 262 |
assert set(categories.json()["metadata"]["categories"]) == EXPECTED_CATEGORIES
|
| 263 |
assert details.json()["metadata"]["template"]["version"] == 1
|
| 264 |
assert execution.status_code == 200
|
| 265 |
assert execution.json()["metadata"]["template"]["id"] == "mp3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from fastapi.testclient import TestClient
|
| 8 |
|
| 9 |
from app.container import build_container
|
| 10 |
+
from app.core.exceptions import TemplateExecutionError, TemplateValidationError
|
| 11 |
from app.models.media import InputMedia, MediaSource, ResolvedRequest
|
| 12 |
+
from app.templates.executor import OPERATION_BINDINGS, TemplateExecutor
|
| 13 |
from app.templates.loader import TemplateLoader
|
| 14 |
from app.templates.registry import TemplateRegistry
|
| 15 |
from app.templates.validator import TemplateValidator
|
| 16 |
from main import create_app
|
| 17 |
|
| 18 |
+
|
| 19 |
+
async def fake_audio_output_probe(path: Path):
|
| 20 |
+
return {
|
| 21 |
+
"duration": 1.0,
|
| 22 |
+
"container": "mp3",
|
| 23 |
+
"video_streams": [],
|
| 24 |
+
"audio_streams": [{"codec": "mp3"}],
|
| 25 |
+
"resolution": None,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
EXPECTED_CATEGORIES = {
|
| 30 |
"branding",
|
| 31 |
"conversion",
|
|
|
|
| 43 |
def test_builtin_templates_are_loaded_dynamically(settings) -> None:
|
| 44 |
registry = build_container(settings).template_registry
|
| 45 |
|
| 46 |
+
assert registry.count == 73
|
| 47 |
assert set(registry.categories()) == EXPECTED_CATEGORIES
|
| 48 |
assert registry.get("instagram_reel").version == 1
|
| 49 |
assert registry.get("instagram_reel@latest").version == 1
|
|
|
|
| 63 |
assert isinstance(compress_step.parameters["crf"], int)
|
| 64 |
|
| 65 |
|
| 66 |
+
|
| 67 |
+
def test_motivational_word_caption_template_is_validated(settings) -> None:
|
| 68 |
+
registry = build_container(settings).template_registry
|
| 69 |
+
|
| 70 |
+
prepared = registry.prepare("motivational_word_captions", {})
|
| 71 |
+
operations = [step.operation for step in prepared.pipeline]
|
| 72 |
+
|
| 73 |
+
assert operations == ["resize", "transcribe", "burn_subtitles", "normalize_video", "compress"]
|
| 74 |
+
assert prepared.pipeline[1].parameters["word_timestamps"] is True
|
| 75 |
+
assert prepared.pipeline[1].parameters["caption_mode"] == "word"
|
| 76 |
+
assert prepared.pipeline[1].parameters["output_format"] == "srt"
|
| 77 |
+
assert prepared.pipeline[2].inputs == ["artifact:vertical_video", "artifact:captions"]
|
| 78 |
+
assert prepared.pipeline[2].parameters["style"].find("PrimaryColour=") >= 0
|
| 79 |
+
assert prepared.output.format == "mp4"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
def test_template_registry_keeps_old_versions(tmp_path: Path) -> None:
|
| 83 |
root = tmp_path / "templates"
|
| 84 |
root.mkdir()
|
|
|
|
| 219 |
output.write_bytes(b"ID3-template-output")
|
| 220 |
|
| 221 |
monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
|
| 222 |
+
monkeypatch.setattr(container.processor.ffprobe, "probe", fake_audio_output_probe)
|
| 223 |
monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
|
| 224 |
resolved = ResolvedRequest(
|
| 225 |
request_id="5fdbe750-4cb7-4f87-aa5c-3df50c3a6629",
|
|
|
|
| 266 |
output.write_bytes(b"ID3-rest-template")
|
| 267 |
|
| 268 |
monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
|
| 269 |
+
monkeypatch.setattr(container.processor.ffprobe, "probe", fake_audio_output_probe)
|
| 270 |
monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
|
| 271 |
|
| 272 |
with TestClient(application) as client:
|
|
|
|
| 287 |
)
|
| 288 |
|
| 289 |
assert listing.status_code == 200
|
| 290 |
+
assert listing.json()["metadata"]["count"] == 73
|
| 291 |
assert set(categories.json()["metadata"]["categories"]) == EXPECTED_CATEGORIES
|
| 292 |
assert details.json()["metadata"]["template"]["version"] == 1
|
| 293 |
assert execution.status_code == 200
|
| 294 |
assert execution.json()["metadata"]["template"]["id"] == "mp3"
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_voiceover_music_ducking_template_uses_two_original_inputs(settings) -> None:
|
| 299 |
+
registry = build_container(settings).template_registry
|
| 300 |
+
prepared = registry.prepare("voiceover_music_ducking", {})
|
| 301 |
+
assert [step.operation for step in prepared.pipeline] == ["duck_audio"]
|
| 302 |
+
assert prepared.pipeline[0].inputs == ["original", "original:1"]
|
| 303 |
+
assert prepared.pipeline[0].parameters["music_volume"] == 0.22
|
| 304 |
+
assert prepared.output.format == "mp4"
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_final_output_validation_reports_media_metadata(settings, tmp_path: Path) -> None:
|
| 308 |
+
output = tmp_path / "final.mp3"
|
| 309 |
+
output.write_bytes(b"non-empty-output")
|
| 310 |
+
media = InputMedia(
|
| 311 |
+
source=MediaSource.LOCAL_PATH,
|
| 312 |
+
filename=output.name,
|
| 313 |
+
mime_type="audio/mpeg",
|
| 314 |
+
temp_path=output,
|
| 315 |
+
size=output.stat().st_size,
|
| 316 |
+
duration=12.5,
|
| 317 |
+
metadata={
|
| 318 |
+
"duration": 12.5,
|
| 319 |
+
"container": "mp3",
|
| 320 |
+
"audio_streams": [{"codec": "mp3"}],
|
| 321 |
+
"video_streams": [],
|
| 322 |
+
"resolution": None,
|
| 323 |
+
},
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
report = TemplateExecutor._output_validation(media)
|
| 327 |
+
|
| 328 |
+
assert report["valid"] is True
|
| 329 |
+
assert report["size_bytes"] == output.stat().st_size
|
| 330 |
+
assert report["duration_seconds"] == 12.5
|
| 331 |
+
assert report["audio_stream_count"] == 1
|
| 332 |
+
assert report["video_stream_count"] == 0
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def test_final_output_validation_rejects_empty_artifact(settings, tmp_path: Path) -> None:
|
| 336 |
+
output = tmp_path / "empty.mp3"
|
| 337 |
+
output.write_bytes(b"")
|
| 338 |
+
media = InputMedia(
|
| 339 |
+
source=MediaSource.LOCAL_PATH,
|
| 340 |
+
filename=output.name,
|
| 341 |
+
mime_type="audio/mpeg",
|
| 342 |
+
temp_path=output,
|
| 343 |
+
size=0,
|
| 344 |
+
)
|
| 345 |
+
prepared = build_container(settings).template_registry.prepare("mp3@1", {})
|
| 346 |
+
|
| 347 |
+
with pytest.raises(TemplateExecutionError, match="empty"):
|
| 348 |
+
TemplateExecutor._validate_output(prepared, media)
|