From f385fbdd54fa409099476eb1d822ec105985a358 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sat, 11 Jul 2026 15:28:51 +0200 Subject: [PATCH] refactor(server): extract the media/file-serving routes into routers/media.py (R3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Song audio (/audio/{f}), the local-audio-path resolver (/api/audio-local-path), and raw sloppak-member serving (/api/sloppak/{f}/file/{rel}) — plus the shared _resolve_sloppak_local_file helper — move to lib/routers/media.py. Bodies verbatim except @app->@router and the cache/static path seams (AUDIO_CACHE_DIR-> appstate.audio_cache_dir, STATIC_DIR->appstate.static_dir, SLOPPAK_CACHE_DIR-> appstate.sloppak_cache_dir — all already in the seam). No new slots. The two test fixtures that redirect STATIC_DIR to a temp dir now also patch appstate.static_dir (the moved routes read the seam, not server's global). server.py: 2,638 -> 2,507 (-131). Verified: pyflakes clean; route set identical (143), all unique-path (no catch-all, no shadowing); full pytest 2395 passed (the audio-local-path + sloppak-file-traversal cases). eslint 0. Boot smoke: /audio, /api/sloppak/{}/file serve 404 for unknown, 0 tracebacks. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- docs/size-exemptions.md | 4 +- lib/routers/media.py | 162 +++++++++++++++++++++++++++ server.py | 139 +---------------------- tests/test_audio_local_path.py | 2 + tests/test_sloppak_file_traversal.py | 1 + 6 files changed, 172 insertions(+), 138 deletions(-) create mode 100644 lib/routers/media.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a041311..db738fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. -- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. +- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a `fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 1cdce392..13d4e8c3 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(2,638 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and twenty `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) · +(2,507 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and twenty-one `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/lib/routers/media.py b/lib/routers/media.py new file mode 100644 index 00000000..bcab5ff8 --- /dev/null +++ b/lib/routers/media.py @@ -0,0 +1,162 @@ +"""Media/file-serving routes: song audio (/audio/{f}), the local-audio-path +resolver (/api/audio-local-path), and raw sloppak member serving +(/api/sloppak/{f}/file/{rel}). + +Extracted verbatim from server.py (R3) except @app->@router and the cache/static +path seams (AUDIO_CACHE_DIR->appstate.audio_cache_dir, STATIC_DIR-> +appstate.static_dir, SLOPPAK_CACHE_DIR->appstate.sloppak_cache_dir). +""" + +import ipaddress +import re + +from fastapi import APIRouter, Request +from fastapi.responses import FileResponse, JSONResponse + +import appstate +import sloppak as sloppak_mod +from dlc_paths import _get_dlc_dir, _resolve_dlc_path + +import logging +log = logging.getLogger("feedBack.server") +router = APIRouter() + +def _resolve_sloppak_local_file(filename: str, rel_path: str): + """Resolve a file inside a sloppak to its on-disk path. + + Applies the same containment guards as ``serve_sloppak_file``. Returns the + resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so + callers can produce their endpoint-appropriate response. + """ + dlc = _get_dlc_dir() + if not dlc: + return ("not configured", 404) + # `filename` is caller-controlled. Contain it under DLC_DIR before it + # reaches the resolver (see serve_sloppak_file for the traversal rationale). + resolved = _resolve_dlc_path(dlc, filename) + if resolved is None: + return ("forbidden", 403) + # Confine to actual sloppak bundles — otherwise any plain subdirectory + # would become a read-any-file-under-DLC_DIR source. + if not sloppak_mod.is_sloppak(resolved): + return ("not found", 404) + # Canonicalise the cache key against the resolved path so equivalent URL + # forms of the same sloppak converge on one _source_cache entry. + try: + filename = resolved.relative_to(dlc.resolve()).as_posix() + except ValueError: + # safe_join already proved containment; fail closed regardless. + return ("forbidden", 403) + src = sloppak_mod.get_cached_source_dir(filename) + if src is None: + try: + src = sloppak_mod.resolve_source_dir(filename, dlc, appstate.sloppak_cache_dir) + except Exception: + return ("not found", 404) + # Prevent path traversal within the sloppak. + target = (src / rel_path).resolve() + try: + target.relative_to(src.resolve()) + except ValueError: + return ("forbidden", 403) + if not target.exists() or not target.is_file(): + return ("not found", 404) + return target + + +@router.get("/api/sloppak/{filename:path}/file/{rel_path:path}") +def serve_sloppak_file(filename: str, rel_path: str): + """Serve a file from inside a sloppak (stems, cover, etc.).""" + result = _resolve_sloppak_local_file(filename, rel_path) + if isinstance(result, tuple): + error, status = result + return JSONResponse({"error": error}, status) + target = result + ext = target.suffix.lower() + mt = { + ".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg", + ".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac", + ".m4a": "audio/mp4", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".png": "image/png", ".webp": "image/webp", + ".json": "application/json", + }.get(ext) + return FileResponse(str(target), media_type=mt) if mt else FileResponse(str(target)) + + +@router.get("/api/audio-local-path") +def audio_local_path(url: str, request: Request): + """Return absolute local filesystem path for a song URL (Electron desktop only). + + Accepts ``/audio/`` where ```` may include subdirectory segments — + no scheme, no host, no query string, no fragment. The resolved path must stay + inside appstate.audio_cache_dir or appstate.static_dir; ``..`` traversal, backslashes, and + absolute ``filename`` values are rejected. + + Also accepts ``/api/sloppak//file/`` (percent-encoded, as + emitted by the highway song payload) and resolves it to the unpacked + sloppak cache file via the same containment guards as + ``serve_sloppak_file`` — this lets the desktop engine play a feedpak + full-mix natively under WASAPI-exclusive output. + + This endpoint returns a raw filesystem path and is intended exclusively for + the Electron desktop process (which runs on loopback). Requests from non- + loopback clients are rejected with 403. + """ + # Loopback-only — only the local Electron process should call this + client_host = request.client.host if request.client else None + try: + is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback) + except ValueError: + is_loopback = client_host == "localhost" + if not is_loopback: + return JSONResponse({"error": "forbidden"}, status_code=403) + # Sloppak in-pack file (feedpak full-mix): /api/sloppak//file/. + # Both segments arrive percent-encoded (built with urllib quote() in the + # highway payload); decode before handing to the shared resolver, which + # re-applies all containment guards on the decoded values. + slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url) + if slop_match: + from urllib.parse import unquote + + result = _resolve_sloppak_local_file( + unquote(slop_match.group(1)), unquote(slop_match.group(2)) + ) + if isinstance(result, tuple): + error, status = result + return JSONResponse({"error": error}, status_code=status) + return JSONResponse({"path": str(result)}) + # Accept only simple /audio/ — no scheme, no host, no query/fragment + if not re.fullmatch(r"/audio/[^?#]+", url): + return JSONResponse({"error": "invalid url"}, status_code=400) + filename = url[len("/audio/"):] + # Reject traversal, absolute paths, and backslash separators + if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename: + return JSONResponse({"error": "invalid url"}, status_code=400) + for d in [appstate.audio_cache_dir, appstate.static_dir]: + candidate = (d / filename).resolve() + # Ensure resolved path is inside the allowed directory + try: + candidate.relative_to(d.resolve()) + except ValueError: + continue + if candidate.is_file(): + return JSONResponse({"path": str(candidate)}) + return JSONResponse({"error": "not found"}, status_code=404) + + +@router.get("/audio/{filename:path}") +def serve_audio(filename: str): + """Serve audio files from the writable audio cache directory.""" + # Reject traversal attempts and absolute-path components + if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename: + return JSONResponse({"error": "not found"}, status_code=404) + for d in [appstate.audio_cache_dir, appstate.static_dir]: + candidate = (d / filename).resolve() + try: + candidate.relative_to(d.resolve()) + except ValueError: + continue + if candidate.is_file(): + return FileResponse(str(candidate)) + return JSONResponse({"error": "not found"}, status_code=404) diff --git a/server.py b/server.py index 0cf42fa6..9b4af5f2 100644 --- a/server.py +++ b/server.py @@ -53,6 +53,7 @@ from routers import song as song_router from routers import library as library_router from routers import enrichment as enrichment_routes +from routers import media as media_router import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -65,7 +66,6 @@ import concurrent.futures import inspect -import ipaddress import multiprocessing import re import threading @@ -2460,67 +2460,10 @@ def _running_version() -> str: _extract_cache_lock = threading.Lock() -def _resolve_sloppak_local_file(filename: str, rel_path: str): - """Resolve a file inside a sloppak to its on-disk path. +# ── Media/file-serving routes → routers/media.py (R3) ─────────────────────── +app.include_router(media_router.router) + - Applies the same containment guards as ``serve_sloppak_file``. Returns the - resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so - callers can produce their endpoint-appropriate response. - """ - dlc = _get_dlc_dir() - if not dlc: - return ("not configured", 404) - # `filename` is caller-controlled. Contain it under DLC_DIR before it - # reaches the resolver (see serve_sloppak_file for the traversal rationale). - resolved = _resolve_dlc_path(dlc, filename) - if resolved is None: - return ("forbidden", 403) - # Confine to actual sloppak bundles — otherwise any plain subdirectory - # would become a read-any-file-under-DLC_DIR source. - if not sloppak_mod.is_sloppak(resolved): - return ("not found", 404) - # Canonicalise the cache key against the resolved path so equivalent URL - # forms of the same sloppak converge on one _source_cache entry. - try: - filename = resolved.relative_to(dlc.resolve()).as_posix() - except ValueError: - # safe_join already proved containment; fail closed regardless. - return ("forbidden", 403) - src = sloppak_mod.get_cached_source_dir(filename) - if src is None: - try: - src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) - except Exception: - return ("not found", 404) - # Prevent path traversal within the sloppak. - target = (src / rel_path).resolve() - try: - target.relative_to(src.resolve()) - except ValueError: - return ("forbidden", 403) - if not target.exists() or not target.is_file(): - return ("not found", 404) - return target - - -@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}") -def serve_sloppak_file(filename: str, rel_path: str): - """Serve a file from inside a sloppak (stems, cover, etc.).""" - result = _resolve_sloppak_local_file(filename, rel_path) - if isinstance(result, tuple): - error, status = result - return JSONResponse({"error": error}, status) - target = result - ext = target.suffix.lower() - mt = { - ".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg", - ".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac", - ".m4a": "audio/mp4", - ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".png": "image/png", ".webp": "image/webp", - ".json": "application/json", - }.get(ext) - return FileResponse(str(target), media_type=mt) if mt else FileResponse(str(target)) # ── Highway chart WebSocket ────────────────────────────────────────────────── @@ -2532,82 +2475,8 @@ def serve_sloppak_file(filename: str, rel_path: str): # ── Audio serving ───────────────────────────────────────────────────────────── -@app.get("/api/audio-local-path") -def audio_local_path(url: str, request: Request): - """Return absolute local filesystem path for a song URL (Electron desktop only). - Accepts ``/audio/`` where ```` may include subdirectory segments — - no scheme, no host, no query string, no fragment. The resolved path must stay - inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and - absolute ``filename`` values are rejected. - Also accepts ``/api/sloppak//file/`` (percent-encoded, as - emitted by the highway song payload) and resolves it to the unpacked - sloppak cache file via the same containment guards as - ``serve_sloppak_file`` — this lets the desktop engine play a feedpak - full-mix natively under WASAPI-exclusive output. - - This endpoint returns a raw filesystem path and is intended exclusively for - the Electron desktop process (which runs on loopback). Requests from non- - loopback clients are rejected with 403. - """ - # Loopback-only — only the local Electron process should call this - client_host = request.client.host if request.client else None - try: - is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback) - except ValueError: - is_loopback = client_host == "localhost" - if not is_loopback: - return JSONResponse({"error": "forbidden"}, status_code=403) - # Sloppak in-pack file (feedpak full-mix): /api/sloppak//file/. - # Both segments arrive percent-encoded (built with urllib quote() in the - # highway payload); decode before handing to the shared resolver, which - # re-applies all containment guards on the decoded values. - slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url) - if slop_match: - from urllib.parse import unquote - - result = _resolve_sloppak_local_file( - unquote(slop_match.group(1)), unquote(slop_match.group(2)) - ) - if isinstance(result, tuple): - error, status = result - return JSONResponse({"error": error}, status_code=status) - return JSONResponse({"path": str(result)}) - # Accept only simple /audio/ — no scheme, no host, no query/fragment - if not re.fullmatch(r"/audio/[^?#]+", url): - return JSONResponse({"error": "invalid url"}, status_code=400) - filename = url[len("/audio/"):] - # Reject traversal, absolute paths, and backslash separators - if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename: - return JSONResponse({"error": "invalid url"}, status_code=400) - for d in [AUDIO_CACHE_DIR, STATIC_DIR]: - candidate = (d / filename).resolve() - # Ensure resolved path is inside the allowed directory - try: - candidate.relative_to(d.resolve()) - except ValueError: - continue - if candidate.is_file(): - return JSONResponse({"path": str(candidate)}) - return JSONResponse({"error": "not found"}, status_code=404) - - -@app.get("/audio/{filename:path}") -def serve_audio(filename: str): - """Serve audio files from the writable audio cache directory.""" - # Reject traversal attempts and absolute-path components - if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename: - return JSONResponse({"error": "not found"}, status_code=404) - for d in [AUDIO_CACHE_DIR, STATIC_DIR]: - candidate = (d / filename).resolve() - try: - candidate.relative_to(d.resolve()) - except ValueError: - continue - if candidate.is_file(): - return FileResponse(str(candidate)) - return JSONResponse({"error": "not found"}, status_code=404) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") diff --git a/tests/test_audio_local_path.py b/tests/test_audio_local_path.py index 1d90d65c..07f742af 100644 --- a/tests/test_audio_local_path.py +++ b/tests/test_audio_local_path.py @@ -25,6 +25,7 @@ def client_and_server(tmp_path, monkeypatch): static_tmp = tmp_path / "static" static_tmp.mkdir() monkeypatch.setattr(server, "STATIC_DIR", static_tmp) + monkeypatch.setattr(server.appstate, "static_dir", static_tmp) # Pass client=("127.0.0.1", 50000) so request.client.host is a loopback address test_client = TestClient(server.app, client=("127.0.0.1", 50000)) try: @@ -123,6 +124,7 @@ def dlc_client(tmp_path, monkeypatch): static_tmp = tmp_path / "static" static_tmp.mkdir() monkeypatch.setattr(server, "STATIC_DIR", static_tmp) + monkeypatch.setattr(server.appstate, "static_dir", static_tmp) tc = TestClient(server.app, client=("127.0.0.1", 50000)) try: yield tc, server, dlc diff --git a/tests/test_sloppak_file_traversal.py b/tests/test_sloppak_file_traversal.py index e3f7140e..7280b30a 100644 --- a/tests/test_sloppak_file_traversal.py +++ b/tests/test_sloppak_file_traversal.py @@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch): static_tmp = tmp_path / "static" static_tmp.mkdir() monkeypatch.setattr(server, "STATIC_DIR", static_tmp) + monkeypatch.setattr(server.appstate, "static_dir", static_tmp) tc = TestClient(server.app, client=("127.0.0.1", 50000)) try: yield tc, server, dlc