Security & UX Update: Custom session codes, admin session take-over, secure presentation tokens, and SVG/CSS branding assets
This commit is contained in:
+58
-23
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user