[object Object]

← 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

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 →