feat(career): venue select/unselect UX, intro manifest support, fullmatch guards

- 'Play here' now also defaults the visualization to Venue (remembering
  the prior viz); active venues show 'Leave venue' which restores it and
  sets the '__none__' override so no installed venue silently reapplies.
- Pack manifests may ship an intro block (flyover video + ambience mp3);
  files validate like loops/stingers, .mp3 added to the serving whitelist.
- Codex preflight: whitelist regexes use fullmatch (trailing-newline names
  could validate but 500 on serving).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
byrongamatos
2026-07-12 21:39:41 +02:00
co-authored by Claude Fable 5
parent 702a9c6daa
commit 803193046e
2 changed files with 43 additions and 14 deletions
+11 -8
View File
@@ -35,7 +35,7 @@ from fastapi.responses import FileResponse
PLUGIN_ID = "career"
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|json)$")
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
@@ -109,13 +109,16 @@ def _validate_pack_dir(pack_dir: Path):
loops = manifest.get("loops") or {}
for state in REQUIRED_LOOPS:
name = loops.get(state)
if not name or not PACK_FILENAME_RE.match(name):
if not name or not PACK_FILENAME_RE.fullmatch(name):
raise ValueError(f"manifest is missing the '{state}' loop")
if not (pack_dir / name).is_file():
raise ValueError(f"loop file '{name}' missing from pack")
for name in (manifest.get("stingers") or {}).values():
if name and (not PACK_FILENAME_RE.match(name) or not (pack_dir / name).is_file()):
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"stinger file '{name}' invalid or missing")
for name in (manifest.get("intro") or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"intro file '{name}' invalid or missing")
def _download_pack(venue_id, pack, progress):
@@ -149,7 +152,7 @@ def _download_pack(venue_id, pack, progress):
if info.is_dir():
continue
name = Path(info.filename).name
if name != info.filename or not PACK_FILENAME_RE.match(name):
if name != info.filename or not PACK_FILENAME_RE.fullmatch(name):
raise ValueError(f"unexpected file in pack: {info.filename!r}")
with zf.open(info) as src, open(extract_dir / name, "wb") as dst:
shutil.copyfileobj(src, dst)
@@ -205,7 +208,7 @@ def setup(app, context):
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.match(venue_id) else None
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
if venue is None:
raise HTTPException(404, "Unknown venue.")
pack = venue.get("pack")
@@ -227,7 +230,7 @@ def setup(app, context):
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
def delete_pack(venue_id: str):
if not VENUE_ID_RE.match(venue_id) or _venue(venue_id) is None:
if not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None:
raise HTTPException(404, "Unknown venue.")
with _lock:
running = _state["downloads"].get(venue_id)
@@ -239,7 +242,7 @@ def setup(app, context):
@app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}")
async def get_pack_file(venue_id: str, filename: str):
if not VENUE_ID_RE.match(venue_id) or not PACK_FILENAME_RE.match(filename):
if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename):
raise HTTPException(404, "Not found.")
path = _venue_dir(venue_id) / filename
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
@@ -251,7 +254,7 @@ def setup(app, context):
raise HTTPException(404, "Not found.")
if not resolved.is_file():
raise HTTPException(404, "Not found.")
media = {"mp4": "video/mp4", "webm": "video/webm",
media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg",
"json": "application/json"}[resolved.suffix.lstrip(".").lower()]
return FileResponse(
resolved,
+32 -6
View File
@@ -13,6 +13,8 @@
const API = '/api/plugins/career';
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
const NO_VENUE = '__none__';
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
const POLL_MS = 2000;
let _state = null;
@@ -46,11 +48,12 @@
const gen = ++_manifestReqGen;
const unlocked = state.venues.filter((v) => v.unlocked);
let venue = null;
try {
const override = localStorage.getItem(VENUE_OVERRIDE_KEY);
let override = null;
try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ }
if (override !== NO_VENUE) {
venue = unlocked.find((v) => v.id === override && v.installed) || null;
} catch (_) { /* ok */ }
if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
}
if (!venue) {
if (_appliedManifestVenue !== null) {
_appliedManifestVenue = null;
@@ -82,8 +85,12 @@
action = `<div class="career-bar-track mb-1" style="height:0.375rem"><div class="career-bar-fill" style="width:${pct}%"></div></div>
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
} else if (v.installed) {
const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
const main = active
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
action = `<div class="flex items-center gap-2">
<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>
${main}
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
</div>`;
} else if (v.has_pack) {
@@ -225,9 +232,28 @@
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
.then(refresh);
} else if (playBtn) {
try { localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay); } catch (_) { /* ok */ }
try {
localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay);
// Selecting a venue makes the Venue visualization the default;
// remember what the user had so Leave venue can restore it.
const cur = localStorage.getItem('vizSelection');
if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur);
localStorage.setItem('vizSelection', 'venue');
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* ok */ }
_appliedManifestVenue = null; // force manifest re-push
refresh();
} else if (e.target.closest('[data-career-unselect]')) {
try {
localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE);
const prev = localStorage.getItem(PREV_VIZ_KEY);
if (prev) {
localStorage.setItem('vizSelection', prev);
if (typeof window.setViz === 'function') window.setViz(prev);
}
} catch (_) { /* ok */ }
_appliedManifestVenue = null;
refresh();
}
}