LAPractice/server.js

176 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

'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}`);
});