mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 12:47:10 +00:00
refactor(server): extract the media/file-serving routes into routers/media.py (R3) (#869)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b7624b7e65
commit
0547f55844
+1
-1
@@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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`.
|
- **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
|
- **`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
|
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
|
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
|||||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
## 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`
|
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`
|
(2,507 — 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`) ·
|
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
|
`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
|
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`
|
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||||
|
|||||||
@@ -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/<path>`` where ``<path>`` 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/<filename>/file/<rel>`` (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/<fn>/file/<rel>.
|
||||||
|
# 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/<filename> — 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)
|
||||||
@@ -53,6 +53,7 @@ from routers import settings as settings_router
|
|||||||
from routers import song as song_router
|
from routers import song as song_router
|
||||||
from routers import library as library_router
|
from routers import library as library_router
|
||||||
from routers import enrichment as enrichment_routes
|
from routers import enrichment as enrichment_routes
|
||||||
|
from routers import media as media_router
|
||||||
import sloppak as sloppak_mod
|
import sloppak as sloppak_mod
|
||||||
import loosefolder as loosefolder_mod
|
import loosefolder as loosefolder_mod
|
||||||
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
||||||
@@ -65,7 +66,6 @@ from scan_worker import _extract_meta_for_file, _relpath, _scan_one
|
|||||||
|
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import inspect
|
import inspect
|
||||||
import ipaddress
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
@@ -2460,67 +2460,10 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
|
|||||||
_extract_cache_lock = threading.Lock()
|
_extract_cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_sloppak_local_file(filename: str, rel_path: str):
|
# ── Media/file-serving routes → routers/media.py (R3) ───────────────────────
|
||||||
"""Resolve a file inside a sloppak to its on-disk path.
|
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 ──────────────────────────────────────────────────
|
# ── Highway chart WebSocket ──────────────────────────────────────────────────
|
||||||
@@ -2532,82 +2475,8 @@ app.include_router(ws_highway.router)
|
|||||||
# ── Audio serving ─────────────────────────────────────────────────────────────
|
# ── 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/<path>`` where ``<path>`` 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/<filename>/file/<rel>`` (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/<fn>/file/<rel>.
|
|
||||||
# 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/<filename> — 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")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ def client_and_server(tmp_path, monkeypatch):
|
|||||||
static_tmp = tmp_path / "static"
|
static_tmp = tmp_path / "static"
|
||||||
static_tmp.mkdir()
|
static_tmp.mkdir()
|
||||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
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
|
# 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))
|
test_client = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||||
try:
|
try:
|
||||||
@@ -123,6 +124,7 @@ def dlc_client(tmp_path, monkeypatch):
|
|||||||
static_tmp = tmp_path / "static"
|
static_tmp = tmp_path / "static"
|
||||||
static_tmp.mkdir()
|
static_tmp.mkdir()
|
||||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
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))
|
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||||
try:
|
try:
|
||||||
yield tc, server, dlc
|
yield tc, server, dlc
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch):
|
|||||||
static_tmp = tmp_path / "static"
|
static_tmp = tmp_path / "static"
|
||||||
static_tmp.mkdir()
|
static_tmp.mkdir()
|
||||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
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))
|
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||||
try:
|
try:
|
||||||
yield tc, server, dlc
|
yield tc, server, dlc
|
||||||
|
|||||||
Reference in New Issue
Block a user