← 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
M public/app.jsM public/index.htmlM src/server.js
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 →