const express = require('express'); const http = require('http'); const path = require('path'); const { getEnvConfig, readConfig, writeConfig } = require('./config'); const { listRows, createRow, batchCreateRows, updateRow } = require('./baserow'); const { setupWebSocket, getSessions } = require('./live-session'); const { extractFieldValue } = require('./quiz-logic'); const app = express(); const server = http.createServer(app); app.use(express.json({ limit: '5mb' })); app.use(express.static(path.join(__dirname, '..', 'public'))); function requireAdmin(req, res, next) { if (req.headers['x-admin-token'] !== getEnvConfig().adminPassword) { return res.status(401).json({ error: 'Nicht autorisiert' }); } next(); } /* ============================================================ */ /* Image Proxy */ /* ============================================================ */ const imageCache = new Map(); const MAX_CACHE = 50; app.get('/api/img', async (req, res) => { const url = req.query.url; if (!url) return res.status(400).send('Missing url parameter'); let parsed; try { parsed = new URL(url); } catch { return res.status(400).send('Invalid URL'); } const { baserowUrl } = getEnvConfig(); let allowedHost = ''; try { allowedHost = new URL(baserowUrl).host; } catch { } if (parsed.host !== allowedHost) return res.status(403).send('Forbidden'); if (imageCache.has(url)) { const cached = imageCache.get(url); res.set('Content-Type', cached.contentType); res.set('Cache-Control', 'public, max-age=86400'); return res.send(cached.buffer); } try { const response = await fetch(url); if (!response.ok) return res.status(response.status).send('Image fetch failed'); const contentType = response.headers.get('content-type') || 'image/png'; const buffer = Buffer.from(await response.arrayBuffer()); if (imageCache.size >= MAX_CACHE) { const oldest = imageCache.keys().next().value; imageCache.delete(oldest); } imageCache.set(url, { buffer, contentType }); res.set('Content-Type', contentType); res.set('Cache-Control', 'public, max-age=86400'); res.send(buffer); } catch (e) { console.error(`[img-proxy] Error:`, e.message); res.status(500).send('Proxy error'); } }); /* ============================================================ */ /* Helper: rewrite Baserow image URLs to use our proxy */ /* ============================================================ */ function rewriteImageUrls(rows) { return rows.map((row) => { const bild = row['Bild']; if (bild && Array.isArray(bild) && bild.length > 0) { return { ...row, Bild: bild.map((img) => ({ ...img, url: img.url ? `/api/img?url=${encodeURIComponent(img.url)}` : img.url, })), }; } return row; }); } /* ============================================================ */ /* Public API */ /* ============================================================ */ app.get('/api/sets', (req, res) => { const config = readConfig(); res.json((config.sets || []).map((s) => ({ id: s.id, name: s.name, wettkampfCount: s.wettkampfCount || null, color: s.color || null, wettkampfTimer: s.wettkampfTimer || null }))); }); app.get('/api/sets/:id/questions', async (req, res) => { try { const config = readConfig(); const set = config.sets.find((s) => s.id === req.params.id); if (!set) return res.status(404).json({ error: 'Fragenset nicht gefunden' }); let rows = await listRows(set.tableId); rows = rewriteImageUrls(rows); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/sets/:id/count', async (req, res) => { try { const config = readConfig(); const set = config.sets.find((s) => s.id === req.params.id); if (!set) return res.status(404).json({ error: 'Fragenset nicht gefunden' }); const rows = await listRows(set.tableId); res.json({ count: rows.length }); } catch (e) { res.status(500).json({ error: e.message }); } }); /* ---- Solo/Wettkampf: Save single answer immediately ---- */ app.post('/api/results/single', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); const row = await createRow(config.answersTableId, req.body); res.json({ success: true, id: row.id }); } catch (e) { console.error('[api] Single save error:', e.message); res.status(500).json({ error: e.message }); } }); /* ---- Wettkampf: Update existing answer ---- */ app.put('/api/results/update', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); const { sessionId, frageId, nutzername, antwort } = req.body; if (!sessionId || !frageId || !nutzername) return res.status(400).json({ error: 'sessionId, frageId und nutzername erforderlich' }); // Find the existing row const rows = await listRows(config.answersTableId, { Session_ID: sessionId, Nutzername: nutzername, }); const existing = rows.find((r) => r['Frage_ID'] === frageId); if (!existing) return res.status(404).json({ error: 'Antwort nicht gefunden' }); await updateRow(config.answersTableId, existing.id, { Antwort: antwort }); res.json({ success: true }); } catch (e) { console.error('[api] Update answer error:', e.message); res.status(500).json({ error: e.message }); } }); /* ---- Wettkampf: Finalize scores ---- */ app.put('/api/results/finalize', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); const { results } = req.body; if (!results || !results.length) return res.status(400).json({ error: 'Keine Ergebnisse' }); // results is array of { rowId, richtig, punkte } for (const r of results) { await updateRow(config.answersTableId, r.rowId, { Richtig: r.richtig, Punkte: r.punkte, }); } res.json({ success: true }); } catch (e) { console.error('[api] Finalize error:', e.message); res.status(500).json({ error: e.message }); } }); /* ---- Solo: Check for incomplete session ---- */ app.get('/api/progress/:name/:setId', async (req, res) => { try { const config = readConfig(); const set = config.sets.find((s) => s.id === req.params.setId); if (!set) return res.status(404).json({ error: 'Set nicht gefunden' }); if (!config.answersTableId) return res.json({ hasProgress: false }); // Count total questions const questions = await listRows(set.tableId); const totalQuestions = questions.length; // Get answers for this user + set const allAnswers = await listRows(config.answersTableId, { Nutzername: req.params.name, Fragenset: set.name, }); // Filter to Solo only (Modus may be Single Select object) const soloAnswers = allAnswers.filter((a) => { return extractFieldValue(a['Modus'], '') === 'Solo'; }); // Group by Session_ID const bySession = {}; soloAnswers.forEach((a) => { const sid = a['Session_ID'] || ''; if (!bySession[sid]) bySession[sid] = []; bySession[sid].push(a); }); // Find the most recent incomplete session let best = null; for (const [sid, answers] of Object.entries(bySession)) { if (answers.length > 0 && answers.length < totalQuestions) { const latest = answers.reduce((max, a) => { const ts = a['Zeitstempel'] || ''; return ts > max ? ts : max; }, ''); if (!best || latest > best.latest) { best = { sid, answers, latest }; } } } if (best) { res.json({ hasProgress: true, sessionId: best.sid, answers: best.answers.map((a) => ({ Frage_ID: a['Frage_ID'], Antwort: a['Antwort'] || '', Richtig: !!a['Richtig'], Punkte: a['Punkte'] || 0, })), totalAnswered: best.answers.length, totalCorrect: best.answers.filter((a) => a['Richtig']).length, totalQuestions, }); } else { res.json({ hasProgress: false, totalQuestions }); } } catch (e) { console.error('[api] Progress error:', e.message); res.status(500).json({ error: e.message }); } }); /* ---- Wettkampf: Check for incomplete session ---- */ app.get('/api/wettkampf-progress/:name/:setId', async (req, res) => { try { const config = readConfig(); const set = config.sets.find((s) => s.id === req.params.setId); if (!set) return res.status(404).json({ error: 'Set nicht gefunden' }); if (!config.answersTableId) return res.json({ hasProgress: false }); const allAnswers = await listRows(config.answersTableId, { Nutzername: req.params.name, Fragenset: set.name, }); // Filter to Wettkampf only const wkAnswers = allAnswers.filter((a) => { return extractFieldValue(a['Modus'], '') === 'Wettkampf'; }); // Group by Session_ID const bySession = {}; wkAnswers.forEach((a) => { const sid = a['Session_ID'] || ''; if (!bySession[sid]) bySession[sid] = []; bySession[sid].push(a); }); // Find the most recent session where NOT all answers are finalized (Punkte is still 0 for all) let best = null; for (const [sid, answers] of Object.entries(bySession)) { // A session is "incomplete" if all Punkte are 0 and all Richtig are false (not yet confirmed) const allUnscored = answers.every((a) => { const punkte = Number(a['Punkte']) || 0; const richtig = a['Richtig']; return punkte === 0 && (richtig === false || richtig === 'false' || !richtig); }); const isFullLength = set.wettkampfCount && answers.length >= set.wettkampfCount; if (allUnscored && answers.length > 0 && !isFullLength) { const latest = answers.reduce((max, a) => { const ts = a['Zeitstempel'] || ''; return ts > max ? ts : max; }, ''); if (!best || latest > best.latest) { best = { sid, answers, latest }; } } } if (best) { res.json({ hasProgress: true, sessionId: best.sid, answers: best.answers.map((a) => ({ rowId: a.id, Frage_ID: a['Frage_ID'], Antwort: a['Antwort'] || '', Zeitstempel: a['Zeitstempel'] || '', })), totalAnswered: best.answers.length, }); } else { res.json({ hasProgress: false }); } } catch (e) { console.error('[api] Wettkampf progress error:', e.message); res.status(500).json({ error: e.message }); } }); /* ---- Batch save (still used by live sessions) ---- */ app.post('/api/results', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); await batchCreateRows(config.answersTableId, req.body.answers || []); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/results/abandon', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.status(400).json({ error: 'Nicht konfiguriert' }); const { sessionId, setId, nutzername, expectedCount } = req.body; if (!sessionId || !setId || !nutzername) return res.status(400).json({ error: 'Fehlende Parameter' }); const set = config.sets.find((s) => s.id === setId); if (!set) return res.status(404).json({ error: 'Set nicht gefunden' }); const allAnswers = await listRows(config.answersTableId, { Session_ID: sessionId, Nutzername: nutzername }); const answeredIds = new Set(allAnswers.map(a => Number(a['Frage_ID']))); const needed = expectedCount - allAnswers.length; if (needed > 0) { const questions = await listRows(set.tableId); const unpicked = questions.filter(q => !answeredIds.has(q.id)); const shuffled = unpicked.sort(() => Math.random() - 0.5); const selected = shuffled.slice(0, needed); const batched = selected.map(q => ({ Nutzername: nutzername, Frage_ID: q.id, Fragenset: set.name, Antwort: '', Richtig: false, Punkte: 0, Session_ID: sessionId, Modus: 'Wettkampf', Zeitstempel: new Date().toISOString(), })); await batchCreateRows(config.answersTableId, batched); } res.json({ success: true }); } catch(e) { console.error('[api] Abandon error:', e.message); res.status(500).json({ error: e.message }); } }); app.get('/api/results/:name', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.json([]); const rows = await listRows(config.answersTableId, { Nutzername: req.params.name }); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); } }); /* ============================================================ */ /* Admin API */ /* ============================================================ */ app.post('/api/admin/login', (req, res) => { if (req.body.password === getEnvConfig().adminPassword) { res.json({ success: true, token: getEnvConfig().adminPassword }); } else { res.status(401).json({ error: 'Falsches Passwort' }); } }); app.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig())); app.post('/api/admin/config', requireAdmin, (req, res) => { try { writeConfig(req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/admin/sessions', requireAdmin, (req, res) => res.json(getSessions())); app.get('/api/admin/stats', requireAdmin, async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.json({ answers: [] }); const rows = await listRows(config.answersTableId); res.json({ answers: rows }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/admin/debug-baserow', requireAdmin, async (req, res) => { try { const { baserowUrl, baserowToken } = getEnvConfig(); const config = readConfig(); const result = { baserowUrl, tokenLength: baserowToken.length, sets: config.sets, tests: [] }; for (const set of config.sets) { try { const rows = await listRows(set.tableId); result.tests.push({ set: set.name, tableId: set.tableId, rowCount: rows.length, sampleFields: rows.length > 0 ? Object.keys(rows[0]) : [] }); } catch (e) { result.tests.push({ set: set.name, tableId: set.tableId, error: e.message }); } } res.json(result); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'admin.html'))); app.get('/present', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'present.html'))); app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html'))); setupWebSocket(server); const { port } = getEnvConfig(); server.listen(port, '0.0.0.0', () => { console.log(`[quizalarm] Server laeuft auf Port ${port}`); console.log(`[quizalarm] Baserow URL: ${getEnvConfig().baserowUrl}`); });