From 7c14b423107a41dc12ab4dad9aa93fcbf42aa712 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 09:52:11 +0900 Subject: [PATCH] feat(web): add POST /shrink-batch multi-file upload endpoint The web UI could only shrink one file per request while the CLI handles whole folders. This adds a batch endpoint plus a matching upload form: - POST /shrink-batch accepts up to MAX_BATCH_FILES (20) uploads via FastAPI list[UploadFile], converts each with media_shrinker.convert_file, and returns one ZIP_STORED archive containing every successful output plus a results.json manifest (per-file status, output name, output bytes, error message). - Per-file failures are recorded in the manifest and never abort the rest of the batch. - Validation: 400 for zero files, more than 20 files, or target_bytes <= 0; per-file audio/*|video/* content-type allowlist; per-file MAX_UPLOAD_BYTES cap with the same 1 MB chunked save pattern as /shrink; outputs are only served from inside the request's temp workspace. - Each upload gets isolated input_N/output_N dirs so duplicate filenames cannot collide; zip entries are prefixed with the upload index. - Temp workspace is cleaned up after the response via background task. - Existing /shrink endpoint and its tests are untouched. Gates: 126 tests (baseline 5 macOS os.listxattr errors only), coverage 100% on saas_web.py, interrogate 100%. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CJRVbDrp1vGYkJgNHMGPpG --- saas_web.py | 177 +++++++++++++++++++++++++++++ tests/test_saas_web.py | 245 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 422 insertions(+) diff --git a/saas_web.py b/saas_web.py index 2f177f8..6f4cf34 100644 --- a/saas_web.py +++ b/saas_web.py @@ -1,8 +1,10 @@ """FastAPI upload UI for shrinking one media file through Codec Carver.""" +import json import tempfile import logging import shutil +import zipfile from pathlib import Path from fastapi import FastAPI, UploadFile, File, BackgroundTasks, Form, Request from fastapi.responses import HTMLResponse, FileResponse, JSONResponse @@ -11,6 +13,8 @@ app = FastAPI(title="Codec Carver SaaS") MAX_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024 MAX_REQUEST_BYTES = MAX_UPLOAD_BYTES + 10 * 1024 * 1024 +MAX_BATCH_FILES = 20 +ALLOWED_UPLOAD_CONTENT_PREFIXES = ("audio/", "video/") class RequestTooLarge(Exception): @@ -208,6 +212,22 @@ async def add_security_headers(request: Request, call_next): }, false); +
+

Shrink Multiple Files

+
+

+
+ +
Select several audio or video files. You get back one zip with every output plus a results.json manifest. +

+

+
+ +
Maximum allowed size in bytes for each output file +

+ +
+
""" @@ -303,6 +323,163 @@ def shrink_media( logger.exception("Media processing failed") return {"error": "Upload processing failed"} +def _safe_upload_name(filename): + """Return a directory-free filename for an upload, with a safe fallback. + + Strips any client-supplied directory components and substitutes + ``upload.tmp`` when the remaining name is empty or a dot entry, so a + hostile filename can never escape the request's temp workspace. + """ + + safe_filename = Path(filename or "").name + if not safe_filename or safe_filename in (".", ".."): + safe_filename = "upload.tmp" + return safe_filename + + +def _save_upload_stream(upload, destination: Path): + """Stream one uploaded file to ``destination`` in 1 MB chunks. + + Raises ``ValueError`` as soon as the written bytes exceed + ``MAX_UPLOAD_BYTES`` so oversized uploads are aborted early instead of + filling the disk. Mirrors the save pattern used by the ``/shrink`` + endpoint. + """ + + bytes_written = 0 + with open(destination, "wb") as f: + while chunk := upload.file.read(1024 * 1024): # 1 MB chunks + bytes_written += len(chunk) + if bytes_written > MAX_UPLOAD_BYTES: + raise ValueError("File exceeds maximum allowed upload size") + f.write(chunk) + + +@app.post("/shrink-batch") +def shrink_media_batch( + background_tasks: BackgroundTasks, + files: list[UploadFile] = File(default=[]), + target_bytes: int = Form(2_000_000_000), +): + """Shrink several uploaded media files and return one zip archive. + + Accepts up to ``MAX_BATCH_FILES`` audio/video uploads, converts each one + with :func:`media_shrinker.convert_file`, and responds with a single + ``ZIP_STORED`` archive containing every successful output plus a + ``results.json`` manifest describing the per-file outcome (status, + output name, output size, and error message when a file failed). + + Per-file failures are recorded in the manifest and never abort the rest + of the batch. The whole request body remains bounded by the service-wide + request size middleware, and each individual file is additionally capped + at ``MAX_UPLOAD_BYTES``. The temp workspace is deleted after the response + is sent. + """ + + if target_bytes <= 0: + return JSONResponse( + status_code=400, + content={"error": "Invalid target_bytes value. Must be greater than 0."}, + ) + + if not files: + return JSONResponse(status_code=400, content={"error": "No files uploaded"}) + + if len(files) > MAX_BATCH_FILES: + return JSONResponse( + status_code=400, + content={"error": f"Too many files. Maximum is {MAX_BATCH_FILES} files per batch."}, + ) + + try: + temp_dir_path = Path(tempfile.mkdtemp(prefix="codec_carver_batch_")) + except Exception: + logger.exception("Failed to create batch upload workspace") + return JSONResponse(status_code=500, content={"error": "Upload processing failed"}) + + workspace_root = temp_dir_path.resolve() + manifest = [] + zip_path = temp_dir_path / "codec_carver_batch.zip" + try: + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_STORED) as archive: + for index, upload in enumerate(files): + safe_filename = _safe_upload_name(upload.filename) + entry = { + "index": index, + "filename": safe_filename, + "status": "error", + "output_name": None, + "output_bytes": None, + "error": None, + } + manifest.append(entry) + + content_type = upload.content_type or "" + if not content_type.startswith(ALLOWED_UPLOAD_CONTENT_PREFIXES): + entry["error"] = "Unsupported content type. Only audio/* and video/* uploads are allowed." + continue + + # Each upload gets its own input/output directories so that + # duplicate filenames in one batch can never collide. + input_dir = temp_dir_path / f"input_{index}" + output_dir = temp_dir_path / f"output_{index}" + try: + input_dir.mkdir() + output_dir.mkdir() + source_path = input_dir / safe_filename + _save_upload_stream(upload, source_path) + except Exception: + logger.exception("Failed to prepare batch upload #%d", index) + entry["error"] = "Upload processing failed" + continue + + try: + results = media_shrinker.convert_file( + source=source_path, + root=input_dir, + output_dir=output_dir, + target_bytes=target_bytes, + ) + except Exception: + logger.exception("Batch media processing failed for upload #%d", index) + entry["error"] = "Upload processing failed" + continue + + if not (results and results[0].output_path): + logger.error("Batch processing produced no output for upload #%d: %r", index, results) + entry["error"] = "Processing failed or no output generated" + continue + + output_path = Path(results[0].output_path).resolve() + # Never serve files outside this request's temp workspace. + if not (output_path.is_file() and output_path.is_relative_to(workspace_root)): + logger.error("Batch output for upload #%d is missing or outside the workspace", index) + entry["error"] = "Processing failed or no output generated" + continue + + arcname = f"{index + 1:02d}_{output_path.name}" + archive.write(output_path, arcname=arcname) + entry["status"] = "ok" + entry["output_name"] = arcname + entry["output_bytes"] = output_path.stat().st_size + + archive.writestr( + "results.json", + json.dumps({"target_bytes": target_bytes, "results": manifest}, indent=2), + ) + except Exception: + cleanup_temp_dir(temp_dir_path) + logger.exception("Failed to build batch archive") + return JSONResponse(status_code=500, content={"error": "Upload processing failed"}) + + background_tasks.add_task(cleanup_temp_dir, temp_dir_path) + return FileResponse( + path=zip_path, + filename="codec_carver_batch.zip", + media_type="application/zip", + ) + + if __name__ == "__main__": # pragma: no cover import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 06852fa..cbb0cce 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -1,6 +1,8 @@ import asyncio import io +import json import unittest +import zipfile from unittest.mock import patch, MagicMock from pathlib import Path from types import SimpleNamespace @@ -307,5 +309,248 @@ def test_get_ui_includes_preset_buttons(self): self.assertIn('onclick="setTargetBytes(1073741824)"', html) self.assertIn('function setTargetBytes(bytes)', html) +class TestShrinkBatch(unittest.TestCase): + """Tests for the POST /shrink-batch multi-file endpoint.""" + + @staticmethod + def _fake_convert(source, root, output_dir, target_bytes): + """Fake convert_file that writes a shrunk copy into output_dir.""" + output_path = Path(output_dir) / (Path(source).stem + ".flac") + output_path.write_bytes(b"shrunk:" + Path(source).read_bytes()) + result = MagicMock(spec=ConversionResult) + result.output_path = output_path + return [result] + + @staticmethod + def _read_zip(response): + """Return (namelist, manifest dict, zipfile) for a zip response.""" + archive = zipfile.ZipFile(io.BytesIO(response.content)) + manifest = json.loads(archive.read("results.json")) + return archive.namelist(), manifest, archive + + @patch("saas_web.media_shrinker.convert_file") + def test_shrink_batch_two_files_returns_zip_with_outputs_and_manifest(self, mock_convert_file): + mock_convert_file.side_effect = self._fake_convert + + response = client.post( + "/shrink-batch", + files=[ + ("files", ("a.wav", b"audio-a", "audio/wav")), + ("files", ("b.mp4", b"video-b", "video/mp4")), + ], + data={"target_bytes": 10000}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["content-type"], "application/zip") + names, manifest, archive = self._read_zip(response) + self.assertIn("01_a.flac", names) + self.assertIn("02_b.flac", names) + self.assertIn("results.json", names) + self.assertEqual(archive.read("01_a.flac"), b"shrunk:audio-a") + self.assertEqual(archive.read("02_b.flac"), b"shrunk:video-b") + self.assertEqual(manifest["target_bytes"], 10000) + self.assertEqual(len(manifest["results"]), 2) + self.assertEqual(manifest["results"][0]["status"], "ok") + self.assertEqual(manifest["results"][0]["filename"], "a.wav") + self.assertEqual(manifest["results"][0]["output_name"], "01_a.flac") + self.assertEqual(manifest["results"][0]["output_bytes"], len(b"shrunk:audio-a")) + self.assertEqual(manifest["results"][1]["status"], "ok") + self.assertEqual(mock_convert_file.call_count, 2) + + @patch("saas_web.media_shrinker.convert_file") + def test_shrink_batch_one_failure_does_not_abort_batch(self, mock_convert_file): + def convert(source, root, output_dir, target_bytes): + if Path(source).name == "bad.wav": + raise RuntimeError("/tmp/codec_carver_secret/bad.wav") + return self._fake_convert(source, root, output_dir, target_bytes) + + mock_convert_file.side_effect = convert + + response = client.post( + "/shrink-batch", + files=[ + ("files", ("bad.wav", b"broken", "audio/wav")), + ("files", ("good.wav", b"fine", "audio/wav")), + ], + data={"target_bytes": 10000}, + ) + + self.assertEqual(response.status_code, 200) + names, manifest, archive = self._read_zip(response) + self.assertNotIn("01_bad.flac", names) + self.assertIn("02_good.flac", names) + self.assertEqual(manifest["results"][0]["status"], "error") + self.assertEqual(manifest["results"][0]["error"], "Upload processing failed") + self.assertNotIn("codec_carver_secret", archive.read("results.json").decode()) + self.assertEqual(manifest["results"][1]["status"], "ok") + + def test_shrink_batch_rejects_zero_files(self): + response = client.post("/shrink-batch", data={"target_bytes": 10000}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"error": "No files uploaded"}) + + def test_shrink_batch_rejects_too_many_files(self): + uploads = [ + ("files", (f"f{i}.wav", b"x", "audio/wav")) + for i in range(saas_web.MAX_BATCH_FILES + 1) + ] + response = client.post("/shrink-batch", files=uploads, data={"target_bytes": 10000}) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + {"error": f"Too many files. Maximum is {saas_web.MAX_BATCH_FILES} files per batch."}, + ) + + def test_shrink_batch_rejects_nonpositive_target_bytes(self): + response = client.post( + "/shrink-batch", + files=[("files", ("a.wav", b"audio", "audio/wav"))], + data={"target_bytes": 0}, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + {"error": "Invalid target_bytes value. Must be greater than 0."}, + ) + + @patch("saas_web.media_shrinker.convert_file") + def test_shrink_batch_rejects_disallowed_content_type_per_file(self, mock_convert_file): + mock_convert_file.side_effect = self._fake_convert + + response = client.post( + "/shrink-batch", + files=[ + ("files", ("evil.sh", b"#!/bin/sh", "application/x-sh")), + ("files", ("good.wav", b"fine", "audio/wav")), + ], + data={"target_bytes": 10000}, + ) + + self.assertEqual(response.status_code, 200) + names, manifest, _archive = self._read_zip(response) + self.assertEqual(names, ["02_good.flac", "results.json"]) + self.assertEqual(manifest["results"][0]["status"], "error") + self.assertIn("Unsupported content type", manifest["results"][0]["error"]) + self.assertEqual(manifest["results"][1]["status"], "ok") + mock_convert_file.assert_called_once() + + @patch("saas_web.media_shrinker.convert_file") + def test_shrink_batch_records_no_output_as_error(self, mock_convert_file): + mock_convert_file.return_value = [] + + response = client.post( + "/shrink-batch", + files=[("files", ("a.wav", b"audio", "audio/wav"))], + data={"target_bytes": 10000}, + ) + + self.assertEqual(response.status_code, 200) + names, manifest, _archive = self._read_zip(response) + self.assertEqual(names, ["results.json"]) + self.assertEqual(manifest["results"][0]["status"], "error") + self.assertEqual( + manifest["results"][0]["error"], + "Processing failed or no output generated", + ) + + @patch("saas_web.media_shrinker.convert_file") + def test_shrink_batch_never_serves_output_outside_workspace(self, mock_convert_file): + import tempfile + with tempfile.TemporaryDirectory() as outside_dir: + outside_file = Path(outside_dir) / "secret.flac" + outside_file.write_bytes(b"secret contents") + mock_result = MagicMock(spec=ConversionResult) + mock_result.output_path = outside_file + mock_convert_file.return_value = [mock_result] + + response = client.post( + "/shrink-batch", + files=[("files", ("a.wav", b"audio", "audio/wav"))], + data={"target_bytes": 10000}, + ) + + self.assertEqual(response.status_code, 200) + names, manifest, _archive = self._read_zip(response) + self.assertEqual(names, ["results.json"]) + self.assertEqual(manifest["results"][0]["status"], "error") + self.assertEqual( + manifest["results"][0]["error"], + "Processing failed or no output generated", + ) + self.assertNotIn(b"secret contents", response.content) + + def test_shrink_batch_records_oversized_file_as_error(self): + previous_limit = saas_web.MAX_UPLOAD_BYTES + saas_web.MAX_UPLOAD_BYTES = 3 + try: + response = client.post( + "/shrink-batch", + files=[("files", ("big.wav", b"12345", "audio/wav"))], + data={"target_bytes": 10000}, + ) + finally: + saas_web.MAX_UPLOAD_BYTES = previous_limit + + self.assertEqual(response.status_code, 200) + names, manifest, _archive = self._read_zip(response) + self.assertEqual(names, ["results.json"]) + self.assertEqual(manifest["results"][0]["status"], "error") + self.assertEqual(manifest["results"][0]["error"], "Upload processing failed") + + @patch("saas_web.tempfile.mkdtemp", side_effect=OSError("disk full")) + def test_shrink_batch_handles_workspace_creation_failure(self, _mock_mkdtemp): + response = client.post( + "/shrink-batch", + files=[("files", ("a.wav", b"audio", "audio/wav"))], + data={"target_bytes": 10000}, + ) + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json(), {"error": "Upload processing failed"}) + + @patch("saas_web.zipfile.ZipFile", side_effect=OSError("cannot write zip")) + def test_shrink_batch_handles_archive_failure(self, _mock_zipfile): + response = client.post( + "/shrink-batch", + files=[("files", ("a.wav", b"audio", "audio/wav"))], + data={"target_bytes": 10000}, + ) + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json(), {"error": "Upload processing failed"}) + + @patch("saas_web.media_shrinker.convert_file") + def test_shrink_batch_uses_safe_fallback_filename(self, mock_convert_file): + mock_convert_file.return_value = [] + + response = saas_web.shrink_media_batch( + BackgroundTasks(), + files=[ + SimpleNamespace( + filename="..", + content_type="audio/wav", + file=io.BytesIO(b"dummy"), + ) + ], + target_bytes=10000, + ) + + archive = zipfile.ZipFile(response.path) + manifest = json.loads(archive.read("results.json")) + self.assertEqual(manifest["results"][0]["filename"], "upload.tmp") + self.assertEqual( + mock_convert_file.call_args.kwargs["source"].name, "upload.tmp" + ) + saas_web.cleanup_temp_dir(Path(response.path).parent) + + def test_get_ui_includes_batch_upload_form(self): + response = client.get("/") + self.assertEqual(response.status_code, 200) + html = response.text + self.assertIn('action="/shrink-batch"', html) + self.assertIn('id="batch_files"', html) + self.assertIn('multiple', html) + self.assertIn('accept="audio/*,video/*"', html) + + if __name__ == '__main__': unittest.main()