Security & UX Update: Custom session codes, admin session take-over, secure presentation tokens, and SVG/CSS branding assets
This commit is contained in:
@@ -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/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-stone-100 text-gray-900 min-h-screen">
|
||||
|
||||
+26
-3
@@ -5,7 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
@@ -13,10 +14,13 @@
|
||||
<style>
|
||||
body {
|
||||
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 {
|
||||
background: #dc2626;
|
||||
}
|
||||
@@ -49,6 +53,7 @@
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
/* Selected state */
|
||||
.answer-a.selected,
|
||||
.answer-b.selected,
|
||||
.answer-c.selected,
|
||||
@@ -57,6 +62,7 @@
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.answer-a.disabled,
|
||||
.answer-b.disabled,
|
||||
.answer-c.disabled,
|
||||
@@ -65,6 +71,23 @@
|
||||
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 {
|
||||
background: linear-gradient(135deg, #dc2626, #f59e0b);
|
||||
-webkit-background-clip: text;
|
||||
|
||||
+116
-97
@@ -228,7 +228,7 @@ function StatsPage() {
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Live Setup Page */
|
||||
/* Live Setup Page — with custom code + reconnect */
|
||||
/* ================================================================== */
|
||||
|
||||
function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||
@@ -236,21 +236,30 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||
const [shuffle, setShuffle] = useState(true);
|
||||
const [scoring, setScoring] = useState('binary');
|
||||
const [timeLimit, setTimeLimit] = useState(30);
|
||||
const [customCode, setCustomCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Reconnect state
|
||||
const [reconnectCode, setReconnectCode] = useState('');
|
||||
const [reconnectError, setReconnectError] = useState('');
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
|
||||
const create = () => {
|
||||
if (!setId) return setError('Bitte ein Fragenset waehlen');
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const ws = new WebSocket(wsUrl());
|
||||
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) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'session-created') {
|
||||
// IMPORTANT: Release WS control before handing off
|
||||
ws.onmessage = null;
|
||||
onSessionCreated(msg.code, ws, {
|
||||
code: msg.code,
|
||||
@@ -259,6 +268,7 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||
options: msg.options,
|
||||
players: [],
|
||||
playerCount: 0,
|
||||
presentToken: msg.presentToken,
|
||||
});
|
||||
} else if (msg.type === 'error') {
|
||||
setError(msg.message);
|
||||
@@ -269,38 +279,96 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||
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 (
|
||||
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto border-t-4 border-red-600">
|
||||
<h2 className="text-xl font-bold mb-4">🎮 Live-Session erstellen</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Fragenset</label>
|
||||
<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 className="space-y-6 max-w-md mx-auto">
|
||||
{/* Create new session */}
|
||||
<div className="bg-white rounded-xl shadow p-6 border-t-4 border-red-600">
|
||||
<h2 className="text-xl font-bold mb-4">🎮 Neue Live-Session</h2>
|
||||
<div className="space-y-4">
|
||||
<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} />
|
||||
<label className="block text-sm font-medium mb-1">Fragenset</label>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
<label className="block text-sm font-medium mb-1">Session-Code <span className="text-gray-400">(optional)</span></label>
|
||||
<input
|
||||
className="w-full p-3 border rounded-lg focus:border-red-500 outline-none uppercase tracking-wider"
|
||||
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>
|
||||
);
|
||||
@@ -321,26 +389,17 @@ function LiveControlPage({ token, code, ws, initialSession }) {
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
// Take over the WS completely with onmessage (no addEventListener confusion)
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
console.log('[admin-control] Received:', msg.type, msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session-created':
|
||||
setSession({
|
||||
code: msg.code,
|
||||
setName: msg.setName,
|
||||
questionCount: msg.questionCount,
|
||||
options: msg.options,
|
||||
players: [],
|
||||
playerCount: 0,
|
||||
code: msg.code, setName: msg.setName, questionCount: msg.questionCount,
|
||||
options: msg.options, players: [], playerCount: 0, presentToken: msg.presentToken,
|
||||
});
|
||||
setPhase('waiting');
|
||||
break;
|
||||
|
||||
case 'admin-joined':
|
||||
console.log('[admin-control] Session synced:', msg.session.players, 'count:', msg.session.playerCount);
|
||||
setSession(msg.session);
|
||||
setLeaderboardData(msg.session.leaderboard || []);
|
||||
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 setPhase('question');
|
||||
break;
|
||||
|
||||
case 'player-joined':
|
||||
case 'player-left':
|
||||
console.log('[admin-control] Player update:', msg.players, 'count:', msg.playerCount);
|
||||
setSession((s) => {
|
||||
const base = s || { code };
|
||||
return { ...base, players: msg.players, playerCount: msg.playerCount };
|
||||
});
|
||||
break;
|
||||
|
||||
case 'game-start':
|
||||
setPhase('active');
|
||||
break;
|
||||
|
||||
case 'question':
|
||||
setPhase('question');
|
||||
setAnswerCount(0);
|
||||
setResult(null);
|
||||
break;
|
||||
|
||||
case 'answer-count':
|
||||
setAnswerCount(msg.count);
|
||||
break;
|
||||
|
||||
case 'game-start': setPhase('active'); break;
|
||||
case 'question': setPhase('question'); setAnswerCount(0); setResult(null); break;
|
||||
case 'answer-count': setAnswerCount(msg.count); break;
|
||||
case 'question-result':
|
||||
setResult(msg);
|
||||
setLeaderboardData(msg.leaderboard);
|
||||
setPhase('result');
|
||||
break;
|
||||
|
||||
setResult(msg); setLeaderboardData(msg.leaderboard); setPhase('result'); break;
|
||||
case 'game-end':
|
||||
setLeaderboardData(msg.leaderboard);
|
||||
setPhase('ended');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[admin-control] Error:', msg.message);
|
||||
break;
|
||||
setLeaderboardData(msg.leaderboard); setPhase('ended'); break;
|
||||
case 'error': console.error('Admin 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 }));
|
||||
|
||||
// Safety net: periodically re-sync session state every 5 seconds while waiting
|
||||
refreshRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(refreshRef.current);
|
||||
ws.onmessage = null;
|
||||
};
|
||||
return () => { clearInterval(refreshRef.current); ws.onmessage = null; };
|
||||
}, [ws, code, token]);
|
||||
|
||||
// Stop polling once game starts
|
||||
useEffect(() => {
|
||||
if (phase !== 'waiting') {
|
||||
clearInterval(refreshRef.current);
|
||||
}
|
||||
if (phase !== 'waiting') clearInterval(refreshRef.current);
|
||||
}, [phase]);
|
||||
|
||||
const start = () => {
|
||||
console.log('[admin-control] Starting game');
|
||||
ws.send(JSON.stringify({ type: 'admin-start', 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 start = () => ws.send(JSON.stringify({ type: 'admin-start', 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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,8 @@ function PresentApp() {
|
||||
useEffect(() => {
|
||||
const code = location.hash.replace('#', '');
|
||||
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) {
|
||||
setPhase('need-config');
|
||||
@@ -176,18 +177,12 @@ function PresentApp() {
|
||||
<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="" />}
|
||||
|
||||
{/* Answer options with feedback colors + vote counts */}
|
||||
{question?.typ?.toLowerCase() !== 'freitext' && (
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
{(question?.antworten || []).map((a) => {
|
||||
const isCorrect = correctKeys.has(a.key);
|
||||
const count = result?.stats?.[a.key] || 0;
|
||||
let cls;
|
||||
if (isCorrect) {
|
||||
cls = 'bg-green-600 ring-4 ring-green-400';
|
||||
} else {
|
||||
cls = 'bg-gray-700 opacity-50';
|
||||
}
|
||||
const cls = isCorrect ? 'bg-green-600 ring-4 ring-green-400' : 'bg-gray-700 opacity-50';
|
||||
return (
|
||||
<div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}>
|
||||
<span>{isCorrect ? '✓ ' : ''}{a.text}</span>
|
||||
@@ -206,7 +201,6 @@ function PresentApp() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leaderboard */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
|
||||
<div className="max-w-xl mx-auto">
|
||||
|
||||
+6
-2
@@ -14,10 +14,13 @@
|
||||
<style>
|
||||
body {
|
||||
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 {
|
||||
background: #dc2626;
|
||||
}
|
||||
@@ -34,6 +37,7 @@
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
/* Fire gradient for headings */
|
||||
.fire-gradient {
|
||||
background: linear-gradient(135deg, #dc2626, #f59e0b);
|
||||
-webkit-background-clip: text;
|
||||
|
||||
+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