fix: XSS event delegation, match btn class, CSS height/mobile, server validation

This commit is contained in:
Jeremy Brandenburger
2026-03-30 14:07:36 +02:00
parent 111f93da44
commit 9995d41c49
4 changed files with 51 additions and 22 deletions
+36 -16
View File
@@ -298,7 +298,7 @@ function updateDashboardOnline(online) {
`<div class="online-user">
<span class="online-dot"></span>
<span style="flex:1;font-family:var(--font-mono)">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u)}">⚔ Challenge</button>
</div>`
).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 `<button class="cat-btn ${active ? 'active' : ''}"
onclick="toggleCategory('${esc(cat)}')">${esc(cat)}</button>`;
data-action="toggle-cat" data-cat="${esc(cat)}">${esc(cat)}</button>`;
}).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() {
`<div class="lobby-player">
<span class="online-dot"></span>
<span class="player-name">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u)}">⚔ Challenge</button>
</div>`
).join('');
}
@@ -520,8 +521,8 @@ function updateLobbyView() {
challEl.innerHTML = inc.map(from =>
`<div class="challenge-item">
<span style="flex:1"><strong>${esc(from)}</strong> challenges you to a duel!</span>
<button class="btn btn-sm btn-accent" onclick="acceptChallenge('${esc(from)}')">Accept</button>
<button class="btn btn-sm btn-muted" onclick="declineChallenge('${esc(from)}')">Decline</button>
<button class="btn btn-sm btn-accent" data-action="accept" data-user="${esc(from)}">Accept</button>
<button class="btn btn-sm btn-muted" data-action="decline" data-user="${esc(from)}">Decline</button>
</div>`
).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) {
<span class="user-role badge-${u.role}">${u.role}</span>
${u.mustChange ? '<span class="badge-warning">temp pw</span>' : ''}
${u.username !== state.user.user
? `<button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(u.username)}')">Delete</button>`
? `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`
: ''}
</div>`
).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');
// 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);
}
+1 -1
View File
@@ -273,7 +273,7 @@
</div>
<div class="match-actions">
<button class="btn btn-accent" id="match-ready-btn" onclick="setReady()" style="display:none">READY</button>
<button class="btn btn-accent hidden" id="match-ready-btn" onclick="setReady()">READY</button>
<button class="btn btn-muted btn-sm" onclick="leaveMatch()">Leave Match</button>
</div>
</div>
+10 -2
View File
@@ -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; }
}
+2 -1
View File
@@ -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(`