Security & UX Update: Custom session codes, admin session take-over, secure presentation tokens, and SVG/CSS branding assets

This commit is contained in:
2026-04-16 18:25:03 +02:00
parent 4b6e43ba65
commit fe3c45b8bf
6 changed files with 214 additions and 134 deletions
+58 -23
View File
@@ -1,4 +1,5 @@
const { WebSocket, WebSocketServer } = require('ws');
const crypto = require('crypto');
const { getEnvConfig, readConfig } = require('./config');
const { listRows, batchCreateRows } = require('./baserow');
const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic');
@@ -12,6 +13,15 @@ function generateCode() {
return code;
}
function sanitizeCode(input) {
// Remove everything except letters and numbers, uppercase
return (input || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
}
function generatePresentToken() {
return crypto.randomBytes(24).toString('hex');
}
function send(ws, data) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
}
@@ -43,6 +53,7 @@ function sessionInfo(s) {
code: s.code, setName: s.setName, status: s.status, options: s.options,
questionCount: s.questions.length, currentQuestionIndex: s.currentQuestionIndex,
playerCount: s.players.size, players: playerList(s), leaderboard: leaderboard(s),
presentToken: s.presentToken,
};
}
@@ -59,6 +70,11 @@ function isAdmin(token) {
return token === getEnvConfig().adminPassword;
}
function isValidPresentToken(token, code) {
const s = sessions.get(code);
return s && s.presentToken === token;
}
function setupWebSocket(server) {
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
@@ -114,7 +130,21 @@ async function adminCreate(ws, msg) {
};
if (opts.shuffle) questions = shuffle(questions);
const code = generateCode();
// Custom or auto-generated code
let code;
if (msg.customCode) {
code = sanitizeCode(msg.customCode);
if (code.length < 3) return send(ws, { type: 'error', message: 'Code muss mindestens 3 Zeichen lang sein' });
if (code.length > 20) return send(ws, { type: 'error', message: 'Code darf maximal 20 Zeichen lang sein' });
if (sessions.has(code)) return send(ws, { type: 'error', message: `Code "${code}" ist bereits vergeben` });
} else {
code = generateCode();
// Ensure uniqueness
while (sessions.has(code)) code = generateCode();
}
const presentToken = generatePresentToken();
const session = {
code, sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(),
setId: set.id, setName: set.name, tableId: set.tableId, options: opts,
@@ -122,34 +152,44 @@ async function adminCreate(ws, msg) {
players: new Map(), admins: new Set([ws]), presents: new Set(),
currentAnswers: new Map(), questionStartTime: null, timer: null,
currentSanitized: null, results: [],
presentToken,
};
sessions.set(code, session);
ws._qa = { code, role: 'admin' };
send(ws, { type: 'session-created', code, setName: set.name, questionCount: questions.length, options: opts });
console.log(`[live] Session created: ${code} (${set.name}, ${questions.length} questions)`);
send(ws, {
type: 'session-created', code, setName: set.name,
questionCount: questions.length, options: opts,
presentToken,
});
}
function adminJoin(ws, msg) {
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
const s = sessions.get(msg.code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
const code = sanitizeCode(msg.code);
const s = sessions.get(code);
if (!s) return send(ws, { type: 'error', message: `Session "${code}" nicht gefunden` });
s.admins.add(ws);
ws._qa = { code: msg.code, role: 'admin' };
ws._qa = { code, role: 'admin' };
send(ws, { type: 'admin-joined', session: sessionInfo(s) });
}
function presentJoin(ws, msg) {
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
const s = sessions.get(msg.code);
const code = sanitizeCode(msg.code);
if (!isAdmin(msg.token) && !isValidPresentToken(msg.token, code)) {
return send(ws, { type: 'error', message: 'Nicht autorisiert' });
}
const s = sessions.get(code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
s.presents.add(ws);
ws._qa = { code: msg.code, role: 'present' };
ws._qa = { code, role: 'present' };
send(ws, { type: 'present-joined', session: sessionInfo(s) });
}
function adminStart(ws, msg) {
if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code);
const s = sessions.get(sanitizeCode(msg.code));
if (!s || s.status !== 'waiting') return;
s.status = 'active';
broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length });
@@ -158,21 +198,21 @@ function adminStart(ws, msg) {
function adminShowResult(ws, msg) {
if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code);
const s = sessions.get(sanitizeCode(msg.code));
if (!s || s.status !== 'active') return;
showResult(s);
}
function adminNext(ws, msg) {
if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code);
const s = sessions.get(sanitizeCode(msg.code));
if (!s || s.status !== 'showing-result') return;
sendNextQuestion(s);
}
async function adminEnd(ws, msg) {
if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code);
const s = sessions.get(sanitizeCode(msg.code));
if (!s) return;
await endSession(s);
}
@@ -180,29 +220,27 @@ async function adminEnd(ws, msg) {
/* ---- Player ---- */
function playerJoin(ws, msg) {
const s = sessions.get(msg.code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden.' });
const code = sanitizeCode(msg.code);
const s = sessions.get(code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den Code.' });
const name = (msg.name || '').trim();
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
// --- Reconnect existing player ---
if (s.players.has(name)) {
const existing = s.players.get(name);
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' });
}
// Reconnect
existing.ws = ws;
ws._qa = { code: msg.code, role: 'player', name };
ws._qa = { code, role: 'player', name };
send(ws, {
type: 'joined',
session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) },
});
// Catch up to current game state
if (s.status === 'active' && s.currentSanitized) {
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
send(ws, { type: 'game-start', totalQuestions: s.questions.length });
@@ -215,7 +253,6 @@ function playerJoin(ws, msg) {
}
} else if (s.status === 'showing-result') {
send(ws, { type: 'game-start', totalQuestions: s.questions.length });
// They'll see the next question when master clicks next
} else if (s.status === 'ended') {
send(ws, { type: 'game-end', leaderboard: leaderboard(s), sessionId: s.sessionId });
}
@@ -224,13 +261,12 @@ function playerJoin(ws, msg) {
return;
}
// --- New player: only allowed in waiting room ---
if (s.status !== 'waiting') {
return send(ws, { type: 'error', message: 'Session laeuft bereits. Nur bestehende Teilnehmer koennen wieder beitreten.' });
}
s.players.set(name, { ws, score: 0, answers: [] });
ws._qa = { code: msg.code, role: 'player', name };
ws._qa = { code, role: 'player', name };
send(ws, {
type: 'joined',
@@ -271,7 +307,7 @@ function sendNextQuestion(s) {
const q = s.questions[s.currentQuestionIndex];
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
const sanitized = sanitizeQuestion(q);
s.currentSanitized = sanitized; // Store for reconnecting players
s.currentSanitized = sanitized;
broadcastAll(s, {
type: 'question', index: s.currentQuestionIndex, total: s.questions.length,
@@ -361,7 +397,6 @@ function handleDisconnect(ws) {
if (!s) return;
if (qa.role === 'admin') s.admins.delete(ws);
else if (qa.role === 'present') s.presents.delete(ws);
// Players stay in the session (they can reconnect)
}
function getSessions() {