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
+5
View File
@@ -11,6 +11,11 @@
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script> <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
}
</style>
</head> </head>
<body class="bg-stone-100 text-gray-900 min-h-screen"> <body class="bg-stone-100 text-gray-900 min-h-screen">
+26 -3
View File
@@ -5,7 +5,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quizalarm</title> <title>Quizalarm</title>
<link rel="icon" href="/img/logo.png"> <link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚨</text></svg>">
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script> <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script> <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
@@ -13,10 +14,13 @@
<style> <style>
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
background: #111111 url('/img/bg.png') center/cover fixed; background: #111111;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
} }
/* Answer button base colors */
.answer-a { .answer-a {
background: #dc2626; background: #dc2626;
} }
@@ -49,6 +53,7 @@
background: #15803d; background: #15803d;
} }
/* Selected state */
.answer-a.selected, .answer-a.selected,
.answer-b.selected, .answer-b.selected,
.answer-c.selected, .answer-c.selected,
@@ -57,6 +62,7 @@
transform: scale(1.03); transform: scale(1.03);
} }
/* Disabled state */
.answer-a.disabled, .answer-a.disabled,
.answer-b.disabled, .answer-b.disabled,
.answer-c.disabled, .answer-c.disabled,
@@ -65,6 +71,23 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Ensure Tailwind doesn't override answer button styles */
button.answer-a,
button.answer-b,
button.answer-c,
button.answer-d {
border: none;
}
button.answer-a.selected,
button.answer-b.selected,
button.answer-c.selected,
button.answer-d.selected {
outline: 4px solid white;
transform: scale(1.03);
}
/* Fire gradient for headings */
.fire-gradient { .fire-gradient {
background: linear-gradient(135deg, #dc2626, #f59e0b); background: linear-gradient(135deg, #dc2626, #f59e0b);
-webkit-background-clip: text; -webkit-background-clip: text;
+116 -97
View File
@@ -228,7 +228,7 @@ function StatsPage() {
} }
/* ================================================================== */ /* ================================================================== */
/* Live Setup Page */ /* Live Setup Page — with custom code + reconnect */
/* ================================================================== */ /* ================================================================== */
function LiveSetupPage({ config, token, onSessionCreated }) { function LiveSetupPage({ config, token, onSessionCreated }) {
@@ -236,21 +236,30 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
const [shuffle, setShuffle] = useState(true); const [shuffle, setShuffle] = useState(true);
const [scoring, setScoring] = useState('binary'); const [scoring, setScoring] = useState('binary');
const [timeLimit, setTimeLimit] = useState(30); const [timeLimit, setTimeLimit] = useState(30);
const [customCode, setCustomCode] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Reconnect state
const [reconnectCode, setReconnectCode] = useState('');
const [reconnectError, setReconnectError] = useState('');
const [reconnecting, setReconnecting] = useState(false);
const create = () => { const create = () => {
if (!setId) return setError('Bitte ein Fragenset waehlen'); if (!setId) return setError('Bitte ein Fragenset waehlen');
setLoading(true); setLoading(true);
setError(''); setError('');
const ws = new WebSocket(wsUrl()); const ws = new WebSocket(wsUrl());
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify({ type: 'admin-create', token, setId, options: { shuffle, scoring, timeLimit } })); ws.send(JSON.stringify({
type: 'admin-create', token, setId,
options: { shuffle, scoring, timeLimit },
customCode: customCode.trim() || null,
}));
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'session-created') { if (msg.type === 'session-created') {
// IMPORTANT: Release WS control before handing off
ws.onmessage = null; ws.onmessage = null;
onSessionCreated(msg.code, ws, { onSessionCreated(msg.code, ws, {
code: msg.code, code: msg.code,
@@ -259,6 +268,7 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
options: msg.options, options: msg.options,
players: [], players: [],
playerCount: 0, playerCount: 0,
presentToken: msg.presentToken,
}); });
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
setError(msg.message); setError(msg.message);
@@ -269,38 +279,96 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); }; ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); };
}; };
const reconnect = () => {
const code = reconnectCode.trim().replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (!code) return setReconnectError('Bitte Code eingeben');
setReconnecting(true);
setReconnectError('');
const ws = new WebSocket(wsUrl());
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'admin-joined') {
ws.onmessage = null;
onSessionCreated(msg.session.code, ws, msg.session);
} else if (msg.type === 'error') {
setReconnectError(msg.message);
setReconnecting(false);
ws.close();
}
};
ws.onerror = () => { setReconnectError('Verbindungsfehler'); setReconnecting(false); };
};
return ( return (
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto border-t-4 border-red-600"> <div className="space-y-6 max-w-md mx-auto">
<h2 className="text-xl font-bold mb-4">🎮 Live-Session erstellen</h2> {/* Create new session */}
<div className="space-y-4"> <div className="bg-white rounded-xl shadow p-6 border-t-4 border-red-600">
<div> <h2 className="text-xl font-bold mb-4">🎮 Neue Live-Session</h2>
<label className="block text-sm font-medium mb-1">Fragenset</label> <div className="space-y-4">
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={setId} onChange={(e) => setSetId(e.target.value)}>
<option value=""> Bitte wählen </option>
{(config.sets || []).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-red-600" />
Fragen mischen
</label>
<div>
<label className="block text-sm font-medium mb-1">Punktevergabe</label>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={scoring} onChange={(e) => setScoring(e.target.value)}>
<option value="binary">Binär (1000 / 0)</option>
<option value="time">Zeitbasiert (schneller = mehr Punkte)</option>
</select>
</div>
{scoring === 'time' && (
<div> <div>
<label className="block text-sm font-medium mb-1">Sekunden pro Frage</label> <label className="block text-sm font-medium mb-1">Fragenset</label>
<input type="number" className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} /> <select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={setId} onChange={(e) => setSetId(e.target.value)}>
<option value=""> Bitte wählen </option>
{(config.sets || []).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div> </div>
)} <div>
{error && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</p>} <label className="block text-sm font-medium mb-1">Session-Code <span className="text-gray-400">(optional)</span></label>
<button onClick={create} disabled={loading} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 disabled:opacity-50"> <input
{loading ? 'Erstelle...' : 'Session erstellen'} className="w-full p-3 border rounded-lg focus:border-red-500 outline-none uppercase tracking-wider"
</button> placeholder="z.B. FEUERWEHR oder leer für Zufall"
value={customCode}
onChange={(e) => setCustomCode(e.target.value)}
maxLength={20}
/>
<p className="text-xs text-gray-400 mt-1">Nur Buchstaben und Zahlen. Mind. 3 Zeichen. Leer = zufällig generiert.</p>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-red-600" />
Fragen mischen
</label>
<div>
<label className="block text-sm font-medium mb-1">Punktevergabe</label>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={scoring} onChange={(e) => setScoring(e.target.value)}>
<option value="binary">Binär (1000 / 0)</option>
<option value="time">Zeitbasiert (schneller = mehr Punkte)</option>
</select>
</div>
{scoring === 'time' && (
<div>
<label className="block text-sm font-medium mb-1">Sekunden pro Frage</label>
<input type="number" className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
</div>
)}
{error && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</p>}
<button onClick={create} disabled={loading} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 disabled:opacity-50">
{loading ? 'Erstelle...' : 'Session erstellen'}
</button>
</div>
</div>
{/* Reconnect to existing session */}
<div className="bg-white rounded-xl shadow p-6 border-t-4 border-amber-500">
<h2 className="text-xl font-bold mb-4">🔄 Bestehende Session übernehmen</h2>
<p className="text-sm text-gray-500 mb-3">Falls die Verbindung unterbrochen wurde, kannst du dich hier wieder mit einer laufenden Session verbinden.</p>
<div className="space-y-3">
<input
className="w-full p-3 border rounded-lg focus:border-amber-500 outline-none uppercase tracking-wider text-center text-lg"
placeholder="Session-Code eingeben"
value={reconnectCode}
onChange={(e) => setReconnectCode(e.target.value)}
maxLength={20}
onKeyDown={(e) => e.key === 'Enter' && reconnect()}
/>
{reconnectError && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{reconnectError}</p>}
<button onClick={reconnect} disabled={reconnecting} className="w-full py-3 bg-amber-500 text-white rounded-lg font-bold hover:bg-amber-600 disabled:opacity-50">
{reconnecting ? 'Verbinde...' : 'Verbinden'}
</button>
</div>
</div> </div>
</div> </div>
); );
@@ -321,26 +389,17 @@ function LiveControlPage({ token, code, ws, initialSession }) {
useEffect(() => { useEffect(() => {
if (!ws) return; if (!ws) return;
// Take over the WS completely with onmessage (no addEventListener confusion)
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
console.log('[admin-control] Received:', msg.type, msg);
switch (msg.type) { switch (msg.type) {
case 'session-created': case 'session-created':
setSession({ setSession({
code: msg.code, code: msg.code, setName: msg.setName, questionCount: msg.questionCount,
setName: msg.setName, options: msg.options, players: [], playerCount: 0, presentToken: msg.presentToken,
questionCount: msg.questionCount,
options: msg.options,
players: [],
playerCount: 0,
}); });
setPhase('waiting'); setPhase('waiting');
break; break;
case 'admin-joined': case 'admin-joined':
console.log('[admin-control] Session synced:', msg.session.players, 'count:', msg.session.playerCount);
setSession(msg.session); setSession(msg.session);
setLeaderboardData(msg.session.leaderboard || []); setLeaderboardData(msg.session.leaderboard || []);
if (msg.session.status === 'waiting') setPhase('waiting'); if (msg.session.status === 'waiting') setPhase('waiting');
@@ -348,86 +407,46 @@ function LiveControlPage({ token, code, ws, initialSession }) {
else if (msg.session.status === 'showing-result') setPhase('result'); else if (msg.session.status === 'showing-result') setPhase('result');
else setPhase('question'); else setPhase('question');
break; break;
case 'player-joined': case 'player-joined':
case 'player-left': case 'player-left':
console.log('[admin-control] Player update:', msg.players, 'count:', msg.playerCount);
setSession((s) => { setSession((s) => {
const base = s || { code }; const base = s || { code };
return { ...base, players: msg.players, playerCount: msg.playerCount }; return { ...base, players: msg.players, playerCount: msg.playerCount };
}); });
break; break;
case 'game-start': setPhase('active'); break;
case 'game-start': case 'question': setPhase('question'); setAnswerCount(0); setResult(null); break;
setPhase('active'); case 'answer-count': setAnswerCount(msg.count); break;
break;
case 'question':
setPhase('question');
setAnswerCount(0);
setResult(null);
break;
case 'answer-count':
setAnswerCount(msg.count);
break;
case 'question-result': case 'question-result':
setResult(msg); setResult(msg); setLeaderboardData(msg.leaderboard); setPhase('result'); break;
setLeaderboardData(msg.leaderboard);
setPhase('result');
break;
case 'game-end': case 'game-end':
setLeaderboardData(msg.leaderboard); setLeaderboardData(msg.leaderboard); setPhase('ended'); break;
setPhase('ended'); case 'error': console.error('Admin error:', msg.message); break;
break;
case 'error':
console.error('[admin-control] Error:', msg.message);
break;
} }
}; };
// Sync current session state from server
console.log('[admin-control] Sending admin-join for code:', code);
ws.send(JSON.stringify({ type: 'admin-join', token, code })); ws.send(JSON.stringify({ type: 'admin-join', token, code }));
// Safety net: periodically re-sync session state every 5 seconds while waiting
refreshRef.current = setInterval(() => { refreshRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'admin-join', token, code })); ws.send(JSON.stringify({ type: 'admin-join', token, code }));
} }
}, 5000); }, 5000);
return () => { return () => { clearInterval(refreshRef.current); ws.onmessage = null; };
clearInterval(refreshRef.current);
ws.onmessage = null;
};
}, [ws, code, token]); }, [ws, code, token]);
// Stop polling once game starts
useEffect(() => { useEffect(() => {
if (phase !== 'waiting') { if (phase !== 'waiting') clearInterval(refreshRef.current);
clearInterval(refreshRef.current);
}
}, [phase]); }, [phase]);
const start = () => { const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code }));
console.log('[admin-control] Starting game'); const showResultAction = () => ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
ws.send(JSON.stringify({ type: 'admin-start', token, code })); const next = () => ws.send(JSON.stringify({ type: 'admin-next', token, code }));
}; const end = () => ws.send(JSON.stringify({ type: 'admin-end', token, code }));
const showResultAction = () => {
ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
};
const next = () => {
ws.send(JSON.stringify({ type: 'admin-next', token, code }));
};
const end = () => {
ws.send(JSON.stringify({ type: 'admin-end', token, code }));
};
const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${code}`; const presentToken = session?.presentToken || '';
const presentUrl = `${location.origin}/present?pt=${encodeURIComponent(presentToken)}#${code}`;
const playerCount = session?.players?.length || session?.playerCount || 0; const playerCount = session?.players?.length || session?.playerCount || 0;
return ( return (
+3 -9
View File
@@ -21,7 +21,8 @@ function PresentApp() {
useEffect(() => { useEffect(() => {
const code = location.hash.replace('#', ''); const code = location.hash.replace('#', '');
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const token = params.get('token'); // Accept both ?pt= (present token) and ?token= (legacy/admin password)
const token = params.get('pt') || params.get('token');
if (!code || !token) { if (!code || !token) {
setPhase('need-config'); setPhase('need-config');
@@ -176,18 +177,12 @@ function PresentApp() {
<h2 className="text-3xl font-bold text-center mb-6">{question?.frage}</h2> <h2 className="text-3xl font-bold text-center mb-6">{question?.frage}</h2>
{question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-xl mb-6" alt="" />} {question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-xl mb-6" alt="" />}
{/* Answer options with feedback colors + vote counts */}
{question?.typ?.toLowerCase() !== 'freitext' && ( {question?.typ?.toLowerCase() !== 'freitext' && (
<div className="grid grid-cols-2 gap-4 mb-8"> <div className="grid grid-cols-2 gap-4 mb-8">
{(question?.antworten || []).map((a) => { {(question?.antworten || []).map((a) => {
const isCorrect = correctKeys.has(a.key); const isCorrect = correctKeys.has(a.key);
const count = result?.stats?.[a.key] || 0; const count = result?.stats?.[a.key] || 0;
let cls; const cls = isCorrect ? 'bg-green-600 ring-4 ring-green-400' : 'bg-gray-700 opacity-50';
if (isCorrect) {
cls = 'bg-green-600 ring-4 ring-green-400';
} else {
cls = 'bg-gray-700 opacity-50';
}
return ( return (
<div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}> <div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}>
<span>{isCorrect ? '✓ ' : ''}{a.text}</span> <span>{isCorrect ? '✓ ' : ''}{a.text}</span>
@@ -206,7 +201,6 @@ function PresentApp() {
</div> </div>
)} )}
{/* Leaderboard */}
<div className="flex-1"> <div className="flex-1">
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3> <h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
<div className="max-w-xl mx-auto"> <div className="max-w-xl mx-auto">
+6 -2
View File
@@ -14,10 +14,13 @@
<style> <style>
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
background: #111111 url('/img/bg.png') center/cover fixed; background: #111111;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
} }
/* Answer colors for presentation view */
.answer-a { .answer-a {
background: #dc2626; background: #dc2626;
} }
@@ -34,6 +37,7 @@
background: #16a34a; background: #16a34a;
} }
/* Fire gradient for headings */
.fire-gradient { .fire-gradient {
background: linear-gradient(135deg, #dc2626, #f59e0b); background: linear-gradient(135deg, #dc2626, #f59e0b);
-webkit-background-clip: text; -webkit-background-clip: text;
+58 -23
View File
@@ -1,4 +1,5 @@
const { WebSocket, WebSocketServer } = require('ws'); const { WebSocket, WebSocketServer } = require('ws');
const crypto = require('crypto');
const { getEnvConfig, readConfig } = require('./config'); const { getEnvConfig, readConfig } = require('./config');
const { listRows, batchCreateRows } = require('./baserow'); const { listRows, batchCreateRows } = require('./baserow');
const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic'); const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic');
@@ -12,6 +13,15 @@ function generateCode() {
return code; 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) { function send(ws, data) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(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, code: s.code, setName: s.setName, status: s.status, options: s.options,
questionCount: s.questions.length, currentQuestionIndex: s.currentQuestionIndex, questionCount: s.questions.length, currentQuestionIndex: s.currentQuestionIndex,
playerCount: s.players.size, players: playerList(s), leaderboard: leaderboard(s), playerCount: s.players.size, players: playerList(s), leaderboard: leaderboard(s),
presentToken: s.presentToken,
}; };
} }
@@ -59,6 +70,11 @@ function isAdmin(token) {
return token === getEnvConfig().adminPassword; return token === getEnvConfig().adminPassword;
} }
function isValidPresentToken(token, code) {
const s = sessions.get(code);
return s && s.presentToken === token;
}
function setupWebSocket(server) { function setupWebSocket(server) {
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
@@ -114,7 +130,21 @@ async function adminCreate(ws, msg) {
}; };
if (opts.shuffle) questions = shuffle(questions); 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 = { const session = {
code, sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(), code, sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(),
setId: set.id, setName: set.name, tableId: set.tableId, options: opts, 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(), players: new Map(), admins: new Set([ws]), presents: new Set(),
currentAnswers: new Map(), questionStartTime: null, timer: null, currentAnswers: new Map(), questionStartTime: null, timer: null,
currentSanitized: null, results: [], currentSanitized: null, results: [],
presentToken,
}; };
sessions.set(code, session); sessions.set(code, session);
ws._qa = { code, role: 'admin' }; 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) { function adminJoin(ws, msg) {
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
const s = sessions.get(msg.code); const code = sanitizeCode(msg.code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' }); const s = sessions.get(code);
if (!s) return send(ws, { type: 'error', message: `Session "${code}" nicht gefunden` });
s.admins.add(ws); s.admins.add(ws);
ws._qa = { code: msg.code, role: 'admin' }; ws._qa = { code, role: 'admin' };
send(ws, { type: 'admin-joined', session: sessionInfo(s) }); send(ws, { type: 'admin-joined', session: sessionInfo(s) });
} }
function presentJoin(ws, msg) { function presentJoin(ws, msg) {
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); const code = sanitizeCode(msg.code);
const s = sessions.get(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' }); if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
s.presents.add(ws); s.presents.add(ws);
ws._qa = { code: msg.code, role: 'present' }; ws._qa = { code, role: 'present' };
send(ws, { type: 'present-joined', session: sessionInfo(s) }); send(ws, { type: 'present-joined', session: sessionInfo(s) });
} }
function adminStart(ws, msg) { function adminStart(ws, msg) {
if (!isAdmin(msg.token)) return; if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code); const s = sessions.get(sanitizeCode(msg.code));
if (!s || s.status !== 'waiting') return; if (!s || s.status !== 'waiting') return;
s.status = 'active'; s.status = 'active';
broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length }); broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length });
@@ -158,21 +198,21 @@ function adminStart(ws, msg) {
function adminShowResult(ws, msg) { function adminShowResult(ws, msg) {
if (!isAdmin(msg.token)) return; if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code); const s = sessions.get(sanitizeCode(msg.code));
if (!s || s.status !== 'active') return; if (!s || s.status !== 'active') return;
showResult(s); showResult(s);
} }
function adminNext(ws, msg) { function adminNext(ws, msg) {
if (!isAdmin(msg.token)) return; 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; if (!s || s.status !== 'showing-result') return;
sendNextQuestion(s); sendNextQuestion(s);
} }
async function adminEnd(ws, msg) { async function adminEnd(ws, msg) {
if (!isAdmin(msg.token)) return; if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code); const s = sessions.get(sanitizeCode(msg.code));
if (!s) return; if (!s) return;
await endSession(s); await endSession(s);
} }
@@ -180,29 +220,27 @@ async function adminEnd(ws, msg) {
/* ---- Player ---- */ /* ---- Player ---- */
function playerJoin(ws, msg) { function playerJoin(ws, msg) {
const s = sessions.get(msg.code); const code = sanitizeCode(msg.code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden.' }); const s = sessions.get(code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den Code.' });
const name = (msg.name || '').trim(); const name = (msg.name || '').trim();
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' }); if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
// --- Reconnect existing player ---
if (s.players.has(name)) { if (s.players.has(name)) {
const existing = s.players.get(name); const existing = s.players.get(name);
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) { if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' }); return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' });
} }
// Reconnect
existing.ws = ws; existing.ws = ws;
ws._qa = { code: msg.code, role: 'player', name }; ws._qa = { code, role: 'player', name };
send(ws, { send(ws, {
type: 'joined', type: 'joined',
session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) }, 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) { if (s.status === 'active' && s.currentSanitized) {
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
send(ws, { type: 'game-start', totalQuestions: s.questions.length }); send(ws, { type: 'game-start', totalQuestions: s.questions.length });
@@ -215,7 +253,6 @@ function playerJoin(ws, msg) {
} }
} else if (s.status === 'showing-result') { } else if (s.status === 'showing-result') {
send(ws, { type: 'game-start', totalQuestions: s.questions.length }); send(ws, { type: 'game-start', totalQuestions: s.questions.length });
// They'll see the next question when master clicks next
} else if (s.status === 'ended') { } else if (s.status === 'ended') {
send(ws, { type: 'game-end', leaderboard: leaderboard(s), sessionId: s.sessionId }); send(ws, { type: 'game-end', leaderboard: leaderboard(s), sessionId: s.sessionId });
} }
@@ -224,13 +261,12 @@ function playerJoin(ws, msg) {
return; return;
} }
// --- New player: only allowed in waiting room ---
if (s.status !== 'waiting') { if (s.status !== 'waiting') {
return send(ws, { type: 'error', message: 'Session laeuft bereits. Nur bestehende Teilnehmer koennen wieder beitreten.' }); return send(ws, { type: 'error', message: 'Session laeuft bereits. Nur bestehende Teilnehmer koennen wieder beitreten.' });
} }
s.players.set(name, { ws, score: 0, answers: [] }); s.players.set(name, { ws, score: 0, answers: [] });
ws._qa = { code: msg.code, role: 'player', name }; ws._qa = { code, role: 'player', name };
send(ws, { send(ws, {
type: 'joined', type: 'joined',
@@ -271,7 +307,7 @@ function sendNextQuestion(s) {
const q = s.questions[s.currentQuestionIndex]; const q = s.questions[s.currentQuestionIndex];
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
const sanitized = sanitizeQuestion(q); const sanitized = sanitizeQuestion(q);
s.currentSanitized = sanitized; // Store for reconnecting players s.currentSanitized = sanitized;
broadcastAll(s, { broadcastAll(s, {
type: 'question', index: s.currentQuestionIndex, total: s.questions.length, type: 'question', index: s.currentQuestionIndex, total: s.questions.length,
@@ -361,7 +397,6 @@ function handleDisconnect(ws) {
if (!s) return; if (!s) return;
if (qa.role === 'admin') s.admins.delete(ws); if (qa.role === 'admin') s.admins.delete(ws);
else if (qa.role === 'present') s.presents.delete(ws); else if (qa.role === 'present') s.presents.delete(ws);
// Players stay in the session (they can reconnect)
} }
function getSessions() { function getSessions() {