"""Plugin-registered FastAPI routes for the 3dhighway visualization plugin. Registered by feedBack core via plugin.json's "routes" field — the loader at plugins/__init__.py:589–604 imports this module and calls setup(app, context). context["config_dir"] points at the feedBack data directory; we namespace user uploads under {config_dir}/plugin_uploads/highway_3d/. This module owns the upload/serve/delete endpoints for the `video` bg style (issue #19 follow-up). Single deterministic slot — each upload replaces the previous file, no orphan accumulation. localStorage on the renderer side stores only the filename, never the bytes. """ import asyncio import os import re import tempfile from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse, JSONResponse from starlette.datastructures import UploadFile PLUGIN_ID = "highway_3d" ALLOWED_VIDEO_EXTS = {"mp4", "webm"} ALLOWED_VIDEO_MIMES = {"video/mp4", "video/webm"} MAX_VIDEO_BYTES = 50 * 1024 * 1024 # 50 MB raw # Filenames the GET endpoint accepts. Tightened to the exact slot # pattern this plugin produces — anything else (leftover upload-*.part # temp files from a crashed upload, future schema additions, manual # disk edits) gets a 404 rather than being served. The previous # permissive regex would have happily streamed a `.part` file to a # client that knew the name. SLOT_FILENAME_RE = re.compile(r"^current\.(mp4|webm)$") def setup(app: FastAPI, context: dict) -> None: config_dir = Path(context["config_dir"]) upload_dir = config_dir / "plugin_uploads" / PLUGIN_ID upload_dir.mkdir(parents=True, exist_ok=True) # Serialises the atomic replace + other-ext cleanup so two concurrent # uploads of different extensions (e.g. mp4 and webm) can't both finish # streaming before either cleans up, leaving both files on disk. Streaming # itself (the slow part) happens outside the lock; only the final # replace + cleanup is held under it — so concurrent uploads of the *same* # extension still overlap for all but the last microsecond. _slot_lock = asyncio.Lock() @app.post(f"/api/plugins/{PLUGIN_ID}/files") async def upload_file(request: Request): # Pre-parse Content-Length guard — fires before ANY body reading. # # FastAPI only reads request.form() when the handler/dependency # explicitly asks for it. By accepting `Request` directly (rather # than `file: UploadFile = File(...)`), we get headers without # consuming the body. If Content-Length already indicates the # upload is too large, we return 413 immediately — python-multipart # never buffers a byte to disk. # # Clients that omit or forge Content-Length fall through to the # streaming chunk-count cap in _do_upload, which remains as a # defence-in-depth fallback. cl = request.headers.get("content-length") if cl is not None: try: cl_int = int(cl) except ValueError: raise HTTPException(400, "Invalid Content-Length header.") if cl_int < 0: raise HTTPException(400, "Invalid Content-Length header.") if cl_int > MAX_VIDEO_BYTES: raise HTTPException( 413, f"Upload exceeds {MAX_VIDEO_BYTES // (1024 * 1024)} MB limit.", ) # Body is only consumed here, after the Content-Length pre-check. form = await request.form() try: file = form.get("file") if not isinstance(file, UploadFile): raise HTTPException(400, "Expected a file upload in field 'file'.") try: return await _do_upload(file) finally: try: await file.close() except Exception: pass finally: # Release the form object (closes any remaining SpooledTemporaryFile # references that weren't already closed by file.close() above). try: await form.close() except Exception: pass async def _do_upload(file: UploadFile): # Extension whitelist is the primary guard — file.filename # (and thus the extension) is always present on a real upload, # whereas content_type is unreliable: some OS / browser combos # report it as empty or as the generic application/octet-stream # for valid .mp4 / .webm files. This mirrors settings.html's # client-side fallback so a working browser doesn't 400 here # after passing the client check. Server-side raw decoding # is left to the browser's