176 lines
5.7 KiB
JavaScript
176 lines
5.7 KiB
JavaScript
'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}`);
|
||
});
|