fix(career): bound the prepare request; validate the setlist (PR #971 review)

Both CodeRabbit findings were right.

1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.

   `await fetch(...)` only rejects on a network ERROR. A server that accepts the
   connection and then never answers hangs indefinitely — and the gig would never
   start. That makes this optimisation the exact thing the PR promises it can
   never be: the reason you cannot play.

   The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
   because unpacking a setlist is real work — but a CEILING, not a wait). Past it
   we start the gig and let the first play extract lazily, as it always did. The
   Play button is restored in a `finally`, so a timeout cannot strand the poster
   on "Preparing set…" with Play disabled — which would have been the same bug
   wearing a different hat.

2. THE `songs` BODY WAS UNVALIDATED.

   A str is iterable: "abc" would have prepared three one-character "songs". And
   the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
   Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.

Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.
This commit is contained in:
byrongamatos
2026-07-15 00:18:56 +02:00
parent a82ea14169
commit 4825ad2e34
4 changed files with 82 additions and 3 deletions
+10 -1
View File
@@ -54,6 +54,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
# arbitrary caller can ask for.
MAX_GIG_SONGS = 32
_lock = threading.Lock()
_state = {
@@ -750,7 +753,13 @@ def setup(app, context):
bad feedpak must not block the set from starting (the play itself will
surface the error, exactly as it does outside a gig).
"""
files = [str(f) for f in ((body or {}).get("songs") or []) if f]
raw = (body or {}).get("songs")
# A str is iterable: without the list check, "abc" would prepare three
# one-character "songs". Cap the count too — this endpoint unpacks zips,
# so an oversized list is real work, and a setlist is a handful of songs.
if not isinstance(raw, list):
return {"ok": True, "prepared": 0, "failed": []}
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
if not files:
return {"ok": True, "prepared": 0, "failed": []}
+18 -2
View File
@@ -12,6 +12,10 @@
'use strict';
const API = '/api/plugins/career';
// Unpacking a setlist is real work (zips, possibly on a slow/network drive),
// so this is generous — but it is a CEILING, not a wait. Past it we start the
// gig and let the first play extract lazily, as it always did.
const PREPARE_TIMEOUT_MS = 60000;
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
const NO_VENUE = '__none__';
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
@@ -1136,14 +1140,26 @@
async function prepareGigSongs(prop, btn) {
const label = btn && btn.textContent;
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
// A bare `await fetch(...)` only rejects on a network ERROR — a server
// that accepts the connection and then never answers hangs forever, and
// the gig would never start. That would make this optimisation the very
// thing it promises never to be: the reason you cannot play. Give up
// waiting and let the first play extract lazily, exactly as before.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
try {
await fetch(`${API}/gigs/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
signal: ctrl.signal,
});
} catch (_) { /* start anyway — first play will extract as it always did */ }
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
} catch (_) {
// abort, offline, non-2xx — all the same: start the gig anyway.
} finally {
clearTimeout(timer);
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
}
}
async function startGig(btn) {