[object Object]

← back to Debate Ring Viewer

[morning-review] debate-ring-viewer: harden round 404 + XSS + Win path + audio resume + a11y

7ccaab0765dd6a95f85b8d077f2223bad65b03f1 · 2026-05-04 12:50:11 -0700 · SteveStudio2

Files touched

Diff

commit 7ccaab0765dd6a95f85b8d077f2223bad65b03f1
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 12:50:11 2026 -0700

    [morning-review] debate-ring-viewer: harden round 404 + XSS + Win path + audio resume + a11y
---
 public/app.js     | 62 ++++++++++++++++++++++++++++++++++------------
 public/index.html |  2 +-
 src/server.js     | 73 +++++++++++++++++++++++++++++++++++++++++++------------
 3 files changed, 106 insertions(+), 31 deletions(-)

diff --git a/public/app.js b/public/app.js
index 96e2eca..5500a01 100644
--- a/public/app.js
+++ b/public/app.js
@@ -33,6 +33,7 @@ let state = {
 function ac(){
   if (!state.sound) return null;
   if (!state.audioCtx) state.audioCtx = new (window.AudioContext||window.webkitAudioContext)();
+  if (state.audioCtx.state === 'suspended') state.audioCtx.resume().catch(()=>{});
   return state.audioCtx;
 }
 function bell(){
@@ -81,6 +82,14 @@ function makeFighters(){
     node.style.top  = SLOTS[i].top  + '%';
     node.querySelector('.nameplate .nm').textContent = f.name;
     node.querySelector('.body text').textContent = f.name.toUpperCase();
+    const svg = node.querySelector('svg.body');
+    if (svg && !svg.querySelector('title')) {
+      const t = document.createElementNS('http://www.w3.org/2000/svg', 'title');
+      t.textContent = f.name + ' fighter';
+      svg.insertBefore(t, svg.firstChild);
+      svg.setAttribute('role', 'img');
+      svg.setAttribute('aria-label', f.name + ' fighter');
+    }
     host.appendChild(node);
   });
 }
@@ -90,16 +99,22 @@ function fighterEl(id){ return $(`.fighter[data-id="${id}"]`); }
 function bubble(id, text){
   const el = fighterEl(id);
   if (!el) return;
-  const slot = SLOTS[FIGHTERS.findIndex(f => f.id===id)];
+  const fIdx = FIGHTERS.findIndex(f => f.id===id);
+  if (fIdx === -1) return;
+  const slot = SLOTS[fIdx];
   const b = document.createElement('div');
   b.className = 'bubble';
   b.style.left = slot.left + '%';
   b.style.top  = slot.top  + '%';
-  const fname = FIGHTERS.find(f => f.id===id).name;
-  b.innerHTML = `<span class="who" style="--shorts:var(--shorts)">${fname}</span>${escapeHtml(text)}`;
+  const fname = FIGHTERS[fIdx].name;
+  const who = document.createElement('span');
+  who.className = 'who';
+  who.textContent = fname;
+  b.appendChild(who);
+  b.appendChild(document.createTextNode(text));
   // tint the WHO label by reading the fighter's CSS variable
   const shorts = getComputedStyle(el).getPropertyValue('--shorts');
-  b.querySelector('.who').style.color = shorts.trim();
+  who.style.color = shorts.trim();
   $('#bubbles').appendChild(b);
   requestAnimationFrame(() => b.classList.add('show'));
   setTimeout(() => { b.classList.remove('show'); setTimeout(() => b.remove(), 400); },
@@ -112,7 +127,9 @@ function escapeHtml(s){
 function impactAt(id){
   const el = fighterEl(id);
   if (!el) return;
-  const slot = SLOTS[FIGHTERS.findIndex(f => f.id===id)];
+  const fIdx = FIGHTERS.findIndex(f => f.id===id);
+  if (fIdx === -1) return;
+  const slot = SLOTS[fIdx];
   const fx = document.createElement('div');
   fx.className = 'impact';
   fx.style.left = (slot.left + (slot.top<50? 6: -6)) + '%';
@@ -164,7 +181,10 @@ async function playRound(n){
   await sleep(500 / state.speed);
   if (aborted()) return;
 
-  const r = await fetch(`/api/runs/${state.runName}/round/${n}`).then(r=>r.json());
+  const r = await fetch(`/api/runs/${state.runName}/round/${n}`)
+    .then(r=>r.json())
+    .catch(() => ({ error: 'fetch failed' }));
+  if (!r || r.error || !r.texts) return;
   const order = FIGHTERS.map(f => f.id)
     .sort(() => Math.random() - .5);  // randomize so it feels chaotic
 
@@ -194,19 +214,25 @@ async function playRound(n){
 
 async function playFromHere(){
   if (state.playing) return;
+  if (!state.manifest || !state.manifest.rounds) return;
   state.playing = true;
   state.abort = { aborted:false };
   $('#play').textContent = '⏸ Pause';
   $('#play').onclick = stop;
-  for (let i=state.idx; i < state.manifest.rounds.length; i++) {
-    if (aborted()) break;
-    state.idx = i;
-    updateNow();
-    await playRound(state.manifest.rounds[i]);
+  try {
+    for (let i=state.idx; i < state.manifest.rounds.length; i++) {
+      if (aborted()) break;
+      state.idx = i;
+      updateNow();
+      await playRound(state.manifest.rounds[i]);
+    }
+  } catch (err) {
+    console.error('playFromHere failed:', err);
+  } finally {
+    state.playing = false;
+    $('#play').textContent = '▶ Fight';
+    $('#play').onclick = playFromHere;
   }
-  state.playing = false;
-  $('#play').textContent = '▶ Fight';
-  $('#play').onclick = playFromHere;
 }
 function stop(){
   if (!state.playing) return;
@@ -231,7 +257,13 @@ async function loadRuns(){
   const ul = $('#runs'); ul.innerHTML='';
   list.forEach(r => {
     const li = document.createElement('li');
-    li.innerHTML = `<span>${r.name}</span><span class="r">${r.rounds}r</span>`;
+    const nm = document.createElement('span');
+    nm.textContent = r.name;
+    const rd = document.createElement('span');
+    rd.className = 'r';
+    rd.textContent = r.rounds + 'r';
+    li.appendChild(nm);
+    li.appendChild(rd);
     li.onclick = () => loadRun(r.name);
     li.dataset.name = r.name;
     ul.appendChild(li);
diff --git a/public/index.html b/public/index.html
index 1915b2e..74d6d6b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -51,7 +51,7 @@
       <div class="ring-light light-l"></div>
       <div class="ring-light light-r"></div>
       <div id="fighters"></div>
-      <div id="bubbles"></div>
+      <div id="bubbles" aria-live="polite" aria-atomic="false"></div>
       <div id="bell"></div>
     </div>
     <div class="hud">
diff --git a/src/server.js b/src/server.js
index 4cf0d28..d438417 100644
--- a/src/server.js
+++ b/src/server.js
@@ -6,26 +6,66 @@ import fs from 'fs/promises';
 import fsync from 'fs';
 import path from 'path';
 import os from 'os';
+import { fileURLToPath } from 'url';
 
 const PORT  = Number(process.env.PORT || 9730);
+const HOST  = process.env.HOST || '127.0.0.1';
 const RUNS  = process.env.CC_RUNS_DIR ||
               path.join(os.homedir(), '.claude/skills/claude-codex/runs');
 
 const FIGHTERS = ['claude','codex','deepseek','gptoss','kimi','mistral','phi4','qwen'];
+const FINAL_REPORT_MAX_BYTES = 8000;
+
+// Reject `.`, `..`, separators, and anything containing `..` segments. The
+// previous regex `^[A-Za-z0-9_.\-]+$` matched `..`, which let `GET /api/runs/..`
+// resolve to the parent of RUNS and exfiltrate any `final_report.md` /
+// `cross_exam.md` sitting there.
+function isSafeRunName(name) {
+  if (typeof name !== 'string' || name.length === 0) return false;
+  if (name === '.' || name === '..') return false;
+  if (name.includes('..')) return false;
+  if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false;
+  return /^[A-Za-z0-9_\-][A-Za-z0-9_.\-]*$/.test(name);
+}
+
+// Read at most `maxBytes` bytes from `fp` without slurping the whole file.
+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');
+  } catch {
+    return '';
+  } finally {
+    if (fh) await fh.close().catch(() => {});
+  }
+}
 
 const app = express();
 app.use(express.json());
-app.use(express.static(path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'public')));
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+app.use(express.static(path.join(__dirname, '..', 'public')));
 
 // ---- helpers ---------------------------------------------------------------
 async function listRuns() {
-  if (!fsync.existsSync(RUNS)) return [];
-  const dirs = await fs.readdir(RUNS, { withFileTypes: true });
+  let dirs;
+  try {
+    dirs = await fs.readdir(RUNS, { withFileTypes: true });
+  } catch {
+    return [];
+  }
   const runs = [];
   for (const d of dirs) {
     if (!d.isDirectory()) continue;
     const full = path.join(RUNS, d.name);
-    const st   = await fs.stat(full);
+    let st;
+    try {
+      st = await fs.stat(full);
+    } catch {
+      continue;
+    }
     let rounds = 0;
     try {
       const items = await fs.readdir(full);
@@ -39,7 +79,11 @@ async function listRuns() {
 
 async function readRound(runName, n) {
   const dir = path.join(RUNS, runName, `round_${n}`);
-  if (!fsync.existsSync(dir)) return null;
+  try {
+    await fs.access(dir);
+  } catch {
+    return null;
+  }
   const out = {};
   for (const f of FIGHTERS) {
     const fp = path.join(dir, `${f}.txt`);
@@ -64,10 +108,8 @@ async function readManifest(runName) {
   let final = '';
   for (const cand of ['final_report.md','cross_exam.md']) {
     const fp = path.join(dir, cand);
-    if (fsync.existsSync(fp)) {
-      final = await fs.readFile(fp, 'utf8').catch(() => '');
-      if (final) { final = final.slice(0, 8000); break; }
-    }
+    const head = await readHead(fp, FINAL_REPORT_MAX_BYTES);
+    if (head) { final = head; break; }
   }
   return { name: runName, rounds, final };
 }
@@ -79,7 +121,7 @@ app.get('/api/runs', async (_req, res) => {
 
 app.get('/api/runs/:name', async (req, res) => {
   const { name } = req.params;
-  if (!/^[A-Za-z0-9_.\-]+$/.test(name)) return res.status(400).json({ error: 'bad name' });
+  if (!isSafeRunName(name)) return res.status(400).json({ error: 'bad name' });
   const m = await readManifest(name);
   if (!m.rounds.length) return res.status(404).json({ error: 'no rounds' });
   res.json(m);
@@ -87,7 +129,7 @@ app.get('/api/runs/:name', async (req, res) => {
 
 app.get('/api/runs/:name/round/:n', async (req, res) => {
   const { name, n } = req.params;
-  if (!/^[A-Za-z0-9_.\-]+$/.test(name)) return res.status(400).json({ error: 'bad name' });
+  if (!isSafeRunName(name))              return res.status(400).json({ error: 'bad name' });
   if (!/^\d+$/.test(n))                  return res.status(400).json({ error: 'bad n' });
   const r = await readRound(name, n);
   if (!r) return res.status(404).json({ error: 'round not found' });
@@ -104,8 +146,9 @@ const WEEKLY_DIR = path.join(os.homedir(),
   '.claude/skills/app-demo-video/output/debate-ring-weekly');
 
 app.get('/demo.mp4', (_req, res) => {
-  if (!fsync.existsSync(DEMO_MP4)) return res.status(404).send('no demo yet — run weekly-highlight-reel.sh');
-  res.sendFile(DEMO_MP4, { headers: { 'content-type': 'video/mp4' } });
+  res.sendFile(DEMO_MP4, { headers: { 'content-type': 'video/mp4' } }, (err) => {
+    if (err && !res.headersSent) res.status(404).send('no demo yet — run weekly-highlight-reel.sh');
+  });
 });
 
 app.get('/demo', (_req, res) => {
@@ -121,6 +164,6 @@ a:hover{background:#1d2028}</style>
 <div class=actions><a href="/demo.mp4" download>⬇ Download MP4</a><a href="/">▶ Open live ring</a></div></div>`);
 });
 
-app.listen(PORT, '0.0.0.0', () => {
-  console.log(`[debate-ring] listening on :${PORT}  runs=${RUNS}`);
+app.listen(PORT, HOST, () => {
+  console.log(`[debate-ring] listening on ${HOST}:${PORT}  runs=${RUNS}`);
 });

← ce2f175 yolo: baseline before overnight  ·  back to Debate Ring Viewer  ·  [morning-review] debate-ring-viewer: cache /demo.mp4, harden 0c2d40d →