← back to Debate Ring Viewer
[morning-review] debate-ring-viewer: cache /demo.mp4, harden avatar SVG injection, a11y labels
0c2d40da0ed5c958f0ccce4b0505f85edd81c517 · 2026-05-04 12:59:31 -0700 · SteveStudio2
- src/server.js: pass cacheControl/maxAge/etag/lastModified to /demo.mp4
sendFile so QuickTime+Safari can short-circuit to 304 instead of
re-downloading the (large) MP4 on every refresh
- scripts/storyboard-snap.cjs: replace template-literal innerHTML for the
avatar SVG with DOMParser + document.importNode, stripping <script>
elements and on* attributes; a hostile avatar file can no longer execute
script in the storyboard page context
- public/index.html: add aria-label to the speed range and sound checkbox
inputs and aria-live to the speed value span so AT users hear changes
Files touched
M public/app.jsM public/index.htmlM scripts/storyboard-snap.cjsM scripts/weekly-highlight-reel.shM src/server.js
Diff
commit 0c2d40da0ed5c958f0ccce4b0505f85edd81c517
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 4 12:59:31 2026 -0700
[morning-review] debate-ring-viewer: cache /demo.mp4, harden avatar SVG injection, a11y labels
- src/server.js: pass cacheControl/maxAge/etag/lastModified to /demo.mp4
sendFile so QuickTime+Safari can short-circuit to 304 instead of
re-downloading the (large) MP4 on every refresh
- scripts/storyboard-snap.cjs: replace template-literal innerHTML for the
avatar SVG with DOMParser + document.importNode, stripping <script>
elements and on* attributes; a hostile avatar file can no longer execute
script in the storyboard page context
- public/index.html: add aria-label to the speed range and sound checkbox
inputs and aria-live to the speed value span so AT users hear changes
---
public/app.js | 23 +++++++++-----
public/index.html | 11 ++++---
scripts/storyboard-snap.cjs | 52 +++++++++++++++++++++++++------
scripts/weekly-highlight-reel.sh | 67 ++++++++++++++++++++++++++++------------
src/server.js | 27 ++++++++++++++--
5 files changed, 137 insertions(+), 43 deletions(-)
diff --git a/public/app.js b/public/app.js
index 5500a01..fd6b360 100644
--- a/public/app.js
+++ b/public/app.js
@@ -26,9 +26,14 @@ const $$ = sel => Array.from(document.querySelectorAll(sel));
let state = {
runs:[], runName:null, manifest:null, idx:0,
playing:false, speed:1.0, sound:true,
- audioCtx:null, abort:null,
+ audioCtx:null, abort:null, runToken:0,
};
+function setControlsEnabled(enabled){
+ const ids = ['play','prev','next'];
+ ids.forEach(id => { const el = document.getElementById(id); if (el) el.disabled = !enabled; });
+}
+
// ── audio ────────────────────────────────────────────────────────────────
function ac(){
if (!state.sound) return null;
@@ -214,14 +219,15 @@ async function playRound(n){
async function playFromHere(){
if (state.playing) return;
- if (!state.manifest || !state.manifest.rounds) return;
+ if (!state.manifest || !Array.isArray(state.manifest.rounds) || !state.manifest.rounds.length) return;
state.playing = true;
state.abort = { aborted:false };
+ const myToken = ++state.runToken;
$('#play').textContent = '⏸ Pause';
$('#play').onclick = stop;
try {
for (let i=state.idx; i < state.manifest.rounds.length; i++) {
- if (aborted()) break;
+ if (aborted() || state.runToken !== myToken) break;
state.idx = i;
updateNow();
await playRound(state.manifest.rounds[i]);
@@ -229,14 +235,17 @@ async function playFromHere(){
} catch (err) {
console.error('playFromHere failed:', err);
} finally {
- state.playing = false;
- $('#play').textContent = '▶ Fight';
- $('#play').onclick = playFromHere;
+ if (state.runToken === myToken) {
+ state.playing = false;
+ $('#play').textContent = '▶ Fight';
+ $('#play').onclick = playFromHere;
+ }
}
}
function stop(){
if (!state.playing) return;
- state.abort.aborted = true;
+ if (state.abort) state.abort.aborted = true;
+ state.runToken++;
state.playing = false;
$('#play').textContent = '▶ Fight';
$('#play').onclick = playFromHere;
diff --git a/public/index.html b/public/index.html
index 74d6d6b..3d6cf07 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,12 +22,13 @@
<button id="play">▶ Fight</button>
<button id="next">Next ▶</button>
</div>
- <label class="speed">Speed
- <input type="range" id="speed" min="0.4" max="2.5" step="0.1" value="1.0" />
- <span id="speedV">1.0×</span>
+ <label class="speed" for="speed">Speed
+ <input type="range" id="speed" min="0.4" max="2.5" step="0.1" value="1.0"
+ aria-label="Playback speed multiplier" />
+ <span id="speedV" aria-live="polite">1.0×</span>
</label>
- <label class="speed">Sound
- <input type="checkbox" id="sound" checked />
+ <label class="speed" for="sound">Sound
+ <input type="checkbox" id="sound" checked aria-label="Toggle sound effects" />
</label>
</div>
<div class="rail-section" id="finalWrap" hidden>
diff --git a/scripts/storyboard-snap.cjs b/scripts/storyboard-snap.cjs
index 04d3506..15773b3 100644
--- a/scripts/storyboard-snap.cjs
+++ b/scripts/storyboard-snap.cjs
@@ -20,19 +20,51 @@ const AVATAR = path.join(__dirname, '..', '..', '..', '.claude/skills/app-demo-v
let svg = '';
try { svg = fs.readFileSync(AVATAR, 'utf8'); } catch {}
if (svg) {
+ // Build the overlay via DOM APIs and parse the avatar SVG with DOMParser
+ // so a hostile avatar file (or one accidentally containing <script>) can't
+ // execute in the page. The previous template-literal `innerHTML = ${svg}`
+ // would have run any embedded handlers.
await page.evaluate(({ svg }) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;right:24px;bottom:24px;width:200px;display:flex;flex-direction:row-reverse;align-items:flex-end;gap:10px;z-index:2147483645;font-family:-apple-system,system-ui,sans-serif';
- wrap.innerHTML = `
- <div style="position:relative;width:140px;height:140px;flex:0 0 140px;filter:drop-shadow(0 8px 18px rgba(0,0,0,.55))">
- <div style="position:absolute;inset:-6px;border-radius:50%;border:3px solid #cc785c;opacity:.8"></div>
- <div style="width:140px;height:140px;border-radius:50%;overflow:hidden;background:#0a1628">${svg}</div>
- <div style="position:absolute;left:-2px;bottom:-4px;background:#0a1628;color:#fffbe6;font-size:10px;letter-spacing:1.4px;padding:3px 8px;border-radius:4px;font-weight:700;border:1px solid #cc785c">STEVE</div>
- </div>
- <div style="max-width:340px;background:#fffbe9;color:#16110a;border:2px solid #1a0e08;border-radius:14px;padding:10px 14px;font-size:13px;line-height:1.4;box-shadow:0 6px 18px rgba(0,0,0,.45)">
- Hey, I'm Steve. This is the Debate Ring — eight LLMs fighting over the words they wrote.
- </div>
- `;
+
+ const card = document.createElement('div');
+ card.style.cssText = 'position:relative;width:140px;height:140px;flex:0 0 140px;filter:drop-shadow(0 8px 18px rgba(0,0,0,.55))';
+
+ const ring = document.createElement('div');
+ ring.style.cssText = 'position:absolute;inset:-6px;border-radius:50%;border:3px solid #cc785c;opacity:.8';
+ card.appendChild(ring);
+
+ const photo = document.createElement('div');
+ photo.style.cssText = 'width:140px;height:140px;border-radius:50%;overflow:hidden;background:#0a1628';
+ try {
+ const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
+ const root = doc.documentElement;
+ if (root && root.nodeName.toLowerCase() === 'svg') {
+ // Strip any <script> elements and on* event-handler attributes that
+ // a malicious avatar file could carry over — DOMParser preserves them.
+ root.querySelectorAll('script').forEach(n => n.remove());
+ root.querySelectorAll('*').forEach(n => {
+ for (const a of Array.from(n.attributes)) {
+ if (/^on/i.test(a.name)) n.removeAttribute(a.name);
+ }
+ });
+ photo.appendChild(document.importNode(root, true));
+ }
+ } catch { /* unparseable svg → empty avatar circle, no XSS */ }
+ card.appendChild(photo);
+
+ const tag = document.createElement('div');
+ tag.style.cssText = 'position:absolute;left:-2px;bottom:-4px;background:#0a1628;color:#fffbe6;font-size:10px;letter-spacing:1.4px;padding:3px 8px;border-radius:4px;font-weight:700;border:1px solid #cc785c';
+ tag.textContent = 'STEVE';
+ card.appendChild(tag);
+
+ const bubble = document.createElement('div');
+ bubble.style.cssText = 'max-width:340px;background:#fffbe9;color:#16110a;border:2px solid #1a0e08;border-radius:14px;padding:10px 14px;font-size:13px;line-height:1.4;box-shadow:0 6px 18px rgba(0,0,0,.45)';
+ bubble.textContent = "Hey, I'm Steve. This is the Debate Ring — eight LLMs fighting over the words they wrote.";
+
+ wrap.appendChild(card);
+ wrap.appendChild(bubble);
document.body.appendChild(wrap);
}, { svg });
}
diff --git a/scripts/weekly-highlight-reel.sh b/scripts/weekly-highlight-reel.sh
index 9a88457..2446c3d 100755
--- a/scripts/weekly-highlight-reel.sh
+++ b/scripts/weekly-highlight-reel.sh
@@ -2,7 +2,12 @@
# Weekly highlight reel — records the most recent claude-codex debate run
# as a 60-second narrated MP4 via the app-demo-video skill, then emails
# Steve a link via George.
-set -u
+set -euo pipefail
+
+# George basic-auth pulled from env. Hardcoded plaintext was committed in
+# baseline ce2f175 — ROTATE that credential; setting GEORGE_AUTH here only
+# stops new copies from being committed.
+: "${GEORGE_AUTH:?GEORGE_AUTH must be set (user:pass for http://localhost:9850)}"
OUTROOT="$HOME/.claude/skills/app-demo-video/output/debate-ring-weekly"
mkdir -p "$OUTROOT"
@@ -11,6 +16,8 @@ mkdir -p "$LOG_DIR"
TS=$(date +%Y%m%d-%H%M%S)
LOG="$LOG_DIR/weekly-reel-$TS.log"
+trap 'echo "[reel] FAILED at line $LINENO (rc=$?)" | tee -a "$LOG"' ERR
+
echo "[reel] start $(date -Iseconds)" | tee -a "$LOG"
# Confirm the viewer is up, otherwise pm2 restart it.
@@ -38,29 +45,38 @@ bash "$HOME/Projects/debate-ring-viewer/scripts/four-horsemen-review.sh" \
"$HOME/Projects/debate-ring-viewer/storyboards/frame-02-fighting.png" "$PRE_REVIEW" >> "$LOG" 2>&1 || true
cd "$HOME/.claude/skills/app-demo-video"
+RC=0
/usr/local/bin/node scripts/run.js \
--url "http://localhost:9730" \
--appName "Debate Ring" \
--flowFile "$HOME/Projects/debate-ring-viewer/demo-flow.json" \
- >> "$LOG" 2>&1
-RC=$?
+ >> "$LOG" 2>&1 || RC=$?
SRC="$HOME/.claude/skills/app-demo-video/output/debate-ring/debate-ring-demo.mp4"
DST="$OUTROOT/debate-ring-$TS.mp4"
-if [ $RC -ne 0 ] || [ ! -s "$SRC" ]; then
- echo "[reel] FAILED rc=$RC, no MP4" | tee -a "$LOG"
+# Build the JSON body via node from env vars so paths/values containing
+# quotes or newlines can't corrupt the JSON literal.
+send_email() {
+ local subject="$1" body="$2" payload
+ payload=$(SUBJECT="$subject" BODY="$body" /usr/local/bin/node -e \
+ 'process.stdout.write(JSON.stringify({to:"steveabramsdesigns@gmail.com",subject:process.env.SUBJECT,body:process.env.BODY}))')
curl -sS --max-time 10 -X POST http://localhost:9850/api/send \
- -u admin:DWSecure2024! \
+ -u "$GEORGE_AUTH" \
-H 'content-type: application/json' \
- -d "$(/usr/local/bin/node -e "process.stdout.write(JSON.stringify({to:'steveabramsdesigns@gmail.com',subject:'Debate-Ring weekly reel FAILED','body':'Reel build failed at '+new Date().toISOString()+'. Log: $LOG'}))")" \
- >> "$LOG" 2>&1 || true
- exit $RC
+ -d "$payload" >> "$LOG" 2>&1 || true
+}
+
+if [ "$RC" -ne 0 ] || [ ! -s "$SRC" ]; then
+ echo "[reel] FAILED rc=$RC, no MP4" | tee -a "$LOG"
+ send_email "Debate-Ring weekly reel FAILED" \
+ "Reel build failed at $(date -Iseconds). Log: $LOG"
+ exit "$RC"
fi
cp "$SRC" "$DST"
-SIZE=$(/usr/bin/stat -f%z "$DST" 2>/dev/null)
-DUR=$(/opt/homebrew/bin/ffprobe -v error -show_entries format=duration -of csv=p=0 "$DST" 2>/dev/null | head -1)
+SIZE=$(/usr/bin/stat -f%z "$DST" 2>/dev/null || echo 0)
+DUR=$(/opt/homebrew/bin/ffprobe -v error -show_entries format=duration -of csv=p=0 "$DST" 2>/dev/null | head -1 || echo "")
echo "[reel] wrote $DST ($SIZE bytes, ${DUR}s)" | tee -a "$LOG"
@@ -71,18 +87,31 @@ KF_DIR="$HOME/Projects/debate-ring-viewer/storyboards/post-build-$TS"
bash "$HOME/Projects/debate-ring-viewer/scripts/keyframes-extract.sh" \
"$DST" "$KF_DIR" 4 >> "$LOG" 2>&1 || true
POST_REVIEW="$REVIEW_DIR/post-build-$TS.md"
-MID_FRAME=$(ls "$KF_DIR"/frame-*.png 2>/dev/null | sed -n '2p')
-if [ -n "$MID_FRAME" ]; then
+MID_FRAME=$(ls "$KF_DIR"/frame-*.png 2>/dev/null | sed -n '2p' || true)
+if [ -n "${MID_FRAME:-}" ]; then
bash "$HOME/Projects/debate-ring-viewer/scripts/four-horsemen-review.sh" \
"$MID_FRAME" "$POST_REVIEW" >> "$LOG" 2>&1 || true
fi
-curl -sS --max-time 10 -X POST http://localhost:9850/api/send \
- -u admin:DWSecure2024! \
- -H 'content-type: application/json' \
- -d "$(/usr/local/bin/node -e "process.stdout.write(JSON.stringify({to:'steveabramsdesigns@gmail.com',subject:'Debate-Ring weekly reel '+new Date().toISOString().slice(0,10),body:'New highlight reel of the latest 8-LLM debate, with Steve avatar narrating from the bottom-right corner.\\n\\nFile: $DST\\nDuration: ${DUR}s\\nSize: $SIZE bytes\\n\\nFour-horsemen design panel reviews:\\n PRE-build: $PRE_REVIEW\\n POST-build: $POST_REVIEW\\n\\nLive viewer: http://localhost:9730\\n\\nOpen with: open $DST'}))")" \
- >> "$LOG" 2>&1 || true
+DATE_TAG=$(date -Iseconds | cut -c1-10)
+SUCCESS_BODY=$(cat <<EOF
+New highlight reel of the latest 8-LLM debate, with Steve avatar narrating from the bottom-right corner.
+
+File: $DST
+Duration: ${DUR}s
+Size: $SIZE bytes
+
+Four-horsemen design panel reviews:
+ PRE-build: $PRE_REVIEW
+ POST-build: $POST_REVIEW
+
+Live viewer: http://localhost:9730
+
+Open with: open $DST
+EOF
+)
+send_email "Debate-Ring weekly reel $DATE_TAG" "$SUCCESS_BODY"
# Trim reels older than 8 weeks
-find "$OUTROOT" -name 'debate-ring-*.mp4' -mtime +56 -delete 2>/dev/null
+find "$OUTROOT" -name 'debate-ring-*.mp4' -mtime +56 -delete 2>/dev/null || true
echo "[reel] end $(date -Iseconds)" | tee -a "$LOG"
diff --git a/src/server.js b/src/server.js
index d438417..3c20540 100644
--- a/src/server.js
+++ b/src/server.js
@@ -29,13 +29,26 @@ function isSafeRunName(name) {
}
// Read at most `maxBytes` bytes from `fp` without slurping the whole file.
+// When we hit the cap, trim back to the last whitespace/newline so we don't
+// cut a multibyte UTF-8 grapheme (emoji/CJK) and emit a replacement char.
async function readHead(fp, maxBytes) {
let fh;
try {
fh = await fs.open(fp, 'r');
const buf = Buffer.allocUnsafe(maxBytes);
const { bytesRead } = await fh.read(buf, 0, maxBytes, 0);
- return buf.slice(0, bytesRead).toString('utf8');
+ if (bytesRead === 0) return '';
+ let end = bytesRead;
+ if (bytesRead === maxBytes) {
+ const half = Math.floor(maxBytes / 2);
+ while (end > half) {
+ const c = buf[end - 1];
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) break;
+ end--;
+ }
+ if (end <= half) end = bytesRead;
+ }
+ return buf.slice(0, end).toString('utf8');
} catch {
return '';
} finally {
@@ -145,8 +158,18 @@ const DEMO_MP4 = path.join(os.homedir(),
const WEEKLY_DIR = path.join(os.homedir(),
'.claude/skills/app-demo-video/output/debate-ring-weekly');
+// Express's send() already sets ETag/Last-Modified, but the prior config
+// didn't pass maxAge/cacheControl, so QuickTime + Safari kept re-downloading
+// the (large) MP4 on every page refresh. Short maxAge + ETag lets conditional
+// GETs short-circuit to 304.
app.get('/demo.mp4', (_req, res) => {
- res.sendFile(DEMO_MP4, { headers: { 'content-type': 'video/mp4' } }, (err) => {
+ res.sendFile(DEMO_MP4, {
+ headers: { 'content-type': 'video/mp4' },
+ cacheControl: true,
+ maxAge: '5m',
+ etag: true,
+ lastModified: true,
+ }, (err) => {
if (err && !res.headersSent) res.status(404).send('no demo yet — run weekly-highlight-reel.sh');
});
});
← 7ccaab0 [morning-review] debate-ring-viewer: harden round 404 + XSS
·
back to Debate Ring Viewer
·
add canonical .gitignore per standing rule 826386e →