mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 08:48:32 +00:00
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:
co-authored by
Claude Fable 5
parent
702a9c6daa
commit
803193046e
@@ -35,7 +35,7 @@ from fastapi.responses import FileResponse
|
|||||||
|
|
||||||
PLUGIN_ID = "career"
|
PLUGIN_ID = "career"
|
||||||
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
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")
|
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
||||||
DOWNLOAD_CHUNK = 1024 * 256
|
DOWNLOAD_CHUNK = 1024 * 256
|
||||||
|
|
||||||
@@ -109,13 +109,16 @@ def _validate_pack_dir(pack_dir: Path):
|
|||||||
loops = manifest.get("loops") or {}
|
loops = manifest.get("loops") or {}
|
||||||
for state in REQUIRED_LOOPS:
|
for state in REQUIRED_LOOPS:
|
||||||
name = loops.get(state)
|
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")
|
raise ValueError(f"manifest is missing the '{state}' loop")
|
||||||
if not (pack_dir / name).is_file():
|
if not (pack_dir / name).is_file():
|
||||||
raise ValueError(f"loop file '{name}' missing from pack")
|
raise ValueError(f"loop file '{name}' missing from pack")
|
||||||
for name in (manifest.get("stingers") or {}).values():
|
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")
|
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):
|
def _download_pack(venue_id, pack, progress):
|
||||||
@@ -149,7 +152,7 @@ def _download_pack(venue_id, pack, progress):
|
|||||||
if info.is_dir():
|
if info.is_dir():
|
||||||
continue
|
continue
|
||||||
name = Path(info.filename).name
|
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}")
|
raise ValueError(f"unexpected file in pack: {info.filename!r}")
|
||||||
with zf.open(info) as src, open(extract_dir / name, "wb") as dst:
|
with zf.open(info) as src, open(extract_dir / name, "wb") as dst:
|
||||||
shutil.copyfileobj(src, dst)
|
shutil.copyfileobj(src, dst)
|
||||||
@@ -205,7 +208,7 @@ def setup(app, context):
|
|||||||
|
|
||||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||||
def start_download(venue_id: str):
|
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:
|
if venue is None:
|
||||||
raise HTTPException(404, "Unknown venue.")
|
raise HTTPException(404, "Unknown venue.")
|
||||||
pack = venue.get("pack")
|
pack = venue.get("pack")
|
||||||
@@ -227,7 +230,7 @@ def setup(app, context):
|
|||||||
|
|
||||||
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
|
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
|
||||||
def delete_pack(venue_id: str):
|
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.")
|
raise HTTPException(404, "Unknown venue.")
|
||||||
with _lock:
|
with _lock:
|
||||||
running = _state["downloads"].get(venue_id)
|
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}}")
|
@app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}")
|
||||||
async def get_pack_file(venue_id: str, filename: str):
|
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.")
|
raise HTTPException(404, "Not found.")
|
||||||
path = _venue_dir(venue_id) / filename
|
path = _venue_dir(venue_id) / filename
|
||||||
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
|
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
|
||||||
@@ -251,7 +254,7 @@ def setup(app, context):
|
|||||||
raise HTTPException(404, "Not found.")
|
raise HTTPException(404, "Not found.")
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
raise HTTPException(404, "Not found.")
|
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()]
|
"json": "application/json"}[resolved.suffix.lstrip(".").lower()]
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
resolved,
|
resolved,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
const API = '/api/plugins/career';
|
const API = '/api/plugins/career';
|
||||||
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||||
|
const NO_VENUE = '__none__';
|
||||||
|
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
let _state = null;
|
let _state = null;
|
||||||
@@ -46,11 +48,12 @@
|
|||||||
const gen = ++_manifestReqGen;
|
const gen = ++_manifestReqGen;
|
||||||
const unlocked = state.venues.filter((v) => v.unlocked);
|
const unlocked = state.venues.filter((v) => v.unlocked);
|
||||||
let venue = null;
|
let venue = null;
|
||||||
try {
|
let override = null;
|
||||||
const override = localStorage.getItem(VENUE_OVERRIDE_KEY);
|
try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ }
|
||||||
|
if (override !== NO_VENUE) {
|
||||||
venue = unlocked.find((v) => v.id === override && v.installed) || null;
|
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 (!venue) {
|
||||||
if (_appliedManifestVenue !== null) {
|
if (_appliedManifestVenue !== null) {
|
||||||
_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>
|
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>`;
|
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
|
||||||
} else if (v.installed) {
|
} 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">
|
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>
|
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (v.has_pack) {
|
} else if (v.has_pack) {
|
||||||
@@ -225,9 +232,28 @@
|
|||||||
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
|
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
|
||||||
.then(refresh);
|
.then(refresh);
|
||||||
} else if (playBtn) {
|
} 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
|
_appliedManifestVenue = null; // force manifest re-push
|
||||||
refresh();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user