From 9995d41c491ca1e3e1075fae83d59a7b3c2870cb Mon Sep 17 00:00:00 2001 From: Jeremy Brandenburger Date: Mon, 30 Mar 2026 14:07:36 +0200 Subject: [PATCH] fix: XSS event delegation, match btn class, CSS height/mobile, server validation --- public/app.js | 56 ++++++++++++++++++++++++++++++++--------------- public/index.html | 2 +- public/styles.css | 12 ++++++++-- server.js | 3 ++- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/public/app.js b/public/app.js index 9917adc..5e6ec3b 100644 --- a/public/app.js +++ b/public/app.js @@ -298,7 +298,7 @@ function updateDashboardOnline(online) { `
${esc(u)} - +
` ).join(''); } @@ -328,7 +328,7 @@ function renderCategoryFilters() { el.innerHTML = cats.map(cat => { const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat); return ``; + data-action="toggle-cat" data-cat="${esc(cat)}">${esc(cat)}`; }).join(''); } @@ -378,6 +378,7 @@ function getPool() { function nextStratagem() { const pool = getPool(); + if (pool.length === 0) { showPracticeIdle(); return; } state.practice.current = pool[Math.floor(Math.random() * pool.length)]; state.practice.progress = 0; state.practice.timeLeft = 30; @@ -505,7 +506,7 @@ function updateLobbyView() { `
${esc(u)} - +
` ).join(''); } @@ -520,8 +521,8 @@ function updateLobbyView() { challEl.innerHTML = inc.map(from => `
${esc(from)} challenges you to a duel! - - + +
` ).join(''); } @@ -573,7 +574,7 @@ function renderMatchWaiting() { const readyBtn = document.getElementById('match-ready-btn'); readyBtn.textContent = 'READY'; readyBtn.disabled = false; - readyBtn.style.display = 'inline-flex'; + readyBtn.classList.remove('hidden'); } function renderMatchScores() { @@ -594,7 +595,7 @@ function renderMatchRound() { setText('match-status', m.current.name); setText('match-category', m.current.category); document.getElementById('match-round-area').classList.remove('hidden'); - document.getElementById('match-ready-btn').style.display = 'none'; + document.getElementById('match-ready-btn').classList.add('hidden'); renderArrows('match-me-sequence', m.current.sequence, 0); renderArrows('match-opp-sequence', m.current.sequence, 0); @@ -629,7 +630,7 @@ function renderRoundResult(winner) { const btn = document.getElementById('match-ready-btn'); btn.textContent = 'Ready for next round'; btn.disabled = false; - btn.style.display = 'inline-flex'; + btn.classList.remove('hidden'); setText('match-category', ''); }, 1600); } @@ -639,8 +640,8 @@ function renderMatchEnd(winner) { setText('match-status', won ? '🏆 MATCH WON!' : '☠ MATCH LOST'); renderMatchScores(); document.getElementById('match-round-area').classList.add('hidden'); - document.getElementById('match-ready-btn').style.display = 'none'; - setTimeout(() => showView('lobby'), 3000); + document.getElementById('match-ready-btn').classList.add('hidden'); + setTimeout(() => { if (state.currentView === 'match') showView('lobby'); }, 3000); } function leaveMatch() { @@ -690,7 +691,7 @@ function renderAdminUsers(users) { ${u.role} ${u.mustChange ? 'temp pw' : ''} ${u.username !== state.user.user - ? `` + ? `` : ''} ` ).join(''); @@ -718,7 +719,7 @@ async function createUser() { } async function deleteUser(username) { - if (!confirm('Delete "' + username + '"? This cannot be undone.')) return; + if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; try { await api('DELETE', '/users/' + encodeURIComponent(username)); loadAdmin(); @@ -727,6 +728,22 @@ async function deleteUser(username) { } } +// ── Event delegation (replaces inline onclick for user-data actions) ────────── +document.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const action = btn.dataset.action; + const user = btn.dataset.user; + const cat = btn.dataset.cat; + + if (action === 'challenge' && user) sendChallenge(user); + if (action === 'accept' && user) acceptChallenge(user); + if (action === 'decline' && user) declineChallenge(user); + if (action === 'delete-user' && user) deleteUser(user); + if (action === 'toggle-cat' && cat) toggleCategory(cat); +}); + // ── Keyboard input ──────────────────────────────────────────────────────────── document.addEventListener('keydown', (e) => { const MAP = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; @@ -762,16 +779,19 @@ function setText(id, value) { function showToast(msg) { const container = document.getElementById('toast-container'); - const toast = document.createElement('div'); - toast.className = 'toast'; + // Limit simultaneous toasts to avoid stacking + if (container.children.length >= 3) container.firstChild?.remove(); + + const toast = document.createElement('div'); + toast.className = 'toast'; toast.textContent = msg; container.appendChild(toast); - requestAnimationFrame(() => { - requestAnimationFrame(() => toast.classList.add('show')); - }); + + requestAnimationFrame(() => requestAnimationFrame(() => toast.classList.add('show'))); + setTimeout(() => { toast.classList.remove('show'); - setTimeout(() => toast.remove(), 300); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); }, 3200); } diff --git a/public/index.html b/public/index.html index 512324e..db7184c 100644 --- a/public/index.html +++ b/public/index.html @@ -273,7 +273,7 @@
- +
diff --git a/public/styles.css b/public/styles.css index bf418b6..a9b4d52 100644 --- a/public/styles.css +++ b/public/styles.css @@ -71,7 +71,7 @@ body::after { .view { position: relative; z-index: 1; - min-height: calc(100vh - 64px); + min-height: calc(100vh - 56px); padding: 24px 20px 48px; max-width: 1100px; margin: 0 auto; @@ -570,6 +570,9 @@ select option { background: var(--bg-surface2); } .timer.flash-wrong { animation: shake 0.4s ease; } +/* Container shake when wrong input in match mode */ +.flash-wrong-seq { animation: shake 0.35s ease; } + /* ── D-Pad ─────────────────────────────────────────────────────────────────── */ .dpad { display: flex; @@ -827,7 +830,12 @@ select option { background: var(--bg-surface2); } .nav-links { display: none; } .dashboard-grid { grid-template-columns: 1fr; } .admin-layout { grid-template-columns: 1fr; } - .match-sequences { grid-template-columns: 1fr; } + .match-sequences { grid-template-columns: 1fr; gap: 12px; } .stratagem-name { font-size: 1.4rem; } .arrow-key { width: 40px; height: 40px; font-size: 1.1rem; } + .match-scoreboard { gap: 16px; } + .match-wins { font-size: 2rem; } + .match-status-text { font-size: 1.3rem; } + .practice-hud { gap: 16px; } + .timer { font-size: 2rem; } } diff --git a/server.js b/server.js index 2f870c0..3f9e973 100644 --- a/server.js +++ b/server.js @@ -253,6 +253,7 @@ app.post('/api/change-password', requireAuth, async (req, res) => { if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(req.session.user); + if (!user) return res.status(404).json({ error: 'User not found' }); const valid = await bcrypt.compare(oldPassword, user.hash); if (!valid) return res.status(401).json({ error: 'Current password incorrect' }); @@ -380,7 +381,7 @@ app.get('/api/dashboard', requireAuth, (req, res) => { app.post('/api/scores/practice', requireAuth, (req, res) => { const { stratagem, category, time_ms, score } = req.body || {}; if (!VALID_NAMES.has(stratagem)) return res.status(400).json({ error: 'Invalid stratagem' }); - if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 35_000) return res.status(400).json({ error: 'Invalid time' }); + if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 31_000) return res.status(400).json({ error: 'Invalid time' }); if (typeof score !== 'number' || score < 0 || score > 15_000) return res.status(400).json({ error: 'Invalid score' }); db.prepare(`