'use strict'; const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const store = require('./db'); const PORT = Number(process.env.PORT) || 4173; const HOST = process.env.HOST || '127.0.0.1'; const PUBLIC_DIR = path.join(__dirname, 'public'); const MAX_UPLOAD = 32 * 1024 * 1024; // 32 MB is plenty for a question set const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff2': 'font/woff2', }; function sendJson(res, status, body) { const payload = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(payload), 'Cache-Control': 'no-store', }); res.end(payload); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (chunk) => { size += chunk.length; if (size > MAX_UPLOAD) { reject(Object.assign(new Error('Datei ist zu gross.'), { status: 413 })); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); if (!raw.trim()) return resolve({}); try { resolve(JSON.parse(raw)); } catch { reject(Object.assign(new Error('Ungueltiges JSON.'), { status: 400 })); } }); req.on('error', reject); }); } function serveStatic(req, res, pathname) { const rel = pathname === '/' ? 'index.html' : pathname.slice(1); const filePath = path.join(PUBLIC_DIR, rel); if (!filePath.startsWith(PUBLIC_DIR + path.sep)) { return sendJson(res, 403, { error: 'Verboten.' }); } fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('404 – nicht gefunden'); } res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream', 'Content-Length': data.length, 'Cache-Control': 'no-cache', }); res.end(data); }); } const routes = [ ['GET', /^\/api\/sets$/, () => ({ sets: store.listSets() })], ['POST', /^\/api\/sets$/, async (req, res, m, body) => { const raw = body && body.data !== undefined ? body.data : body; const result = store.importSet(raw, body && body.filename); return { import: result, sets: store.listSets() }; }], ['GET', /^\/api\/sets\/(\d+)$/, (req, res, m) => { const set = store.getSet(Number(m[1])); if (!set) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 }); return { set, categories: store.getTree(set.id) }; }], ['DELETE', /^\/api\/sets\/(\d+)$/, (req, res, m) => { const changes = store.deleteSet(Number(m[1])); if (!changes) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 }); return { deleted: true, sets: store.listSets() }; }], ['POST', /^\/api\/sets\/(\d+)\/reset$/, (req, res, m) => ({ deletedReviews: store.resetStats(Number(m[1])), })], ['GET', /^\/api\/sets\/(\d+)\/stats$/, (req, res, m) => { const set = store.getSet(Number(m[1])); if (!set) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 }); return { set, ...store.getStats(set.id) }; }], ['POST', /^\/api\/sets\/(\d+)\/session$/, (req, res, m, body) => { const setId = Number(m[1]); if (!store.getSet(setId)) { throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 }); } const ids = Array.isArray(body.subcategoryIds) ? body.subcategoryIds.map(Number).filter(Number.isInteger) : []; const questions = store.buildSession({ setId, subcategoryIds: ids, filter: String(body.filter || 'all'), shuffle: body.shuffle !== false, smart: body.smart !== false, limit: Number(body.limit) || 0, }); return { questions }; }], ['POST', /^\/api\/reviews$/, (req, res, m, body) => { const questionId = Number(body.questionId); if (!Number.isInteger(questionId)) { throw Object.assign(new Error('questionId fehlt.'), { status: 400 }); } if (typeof body.correct !== 'boolean') { throw Object.assign(new Error('correct muss true oder false sein.'), { status: 400 }); } return { stats: store.recordReview(questionId, body.correct) }; }], ]; const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const pathname = decodeURIComponent(url.pathname); if (!pathname.startsWith('/api/')) { if (req.method !== 'GET' && req.method !== 'HEAD') { return sendJson(res, 405, { error: 'Methode nicht erlaubt.' }); } return serveStatic(req, res, pathname); } let pathMatched = false; for (const [method, pattern, handler] of routes) { const m = pattern.exec(pathname); if (!m) continue; pathMatched = true; if (method !== req.method) continue; try { const body = req.method === 'GET' || req.method === 'DELETE' ? {} : await readBody(req); const result = await handler(req, res, m, body); if (!res.writableEnded) sendJson(res, 200, result ?? { ok: true }); } catch (err) { const status = err.status || 400; if (!err.status) console.error(err); if (!res.writableEnded) sendJson(res, status, { error: err.message || 'Fehler.' }); } return; } if (pathMatched) return sendJson(res, 405, { error: 'Methode nicht erlaubt.' }); sendJson(res, 404, { error: 'Unbekannter Endpunkt.' }); }); server.listen(PORT, HOST, () => { console.log(`LAPractice laeuft auf http://${HOST}:${PORT}`); });