'use strict'; const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const store = require('./lib/store'); const { toCsv } = require('./lib/csv'); const PUBLIC_DIR = path.join(__dirname, 'public'); const DAY_MS = 24 * 60 * 60 * 1000; const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', }; /* ------------------------------------------------------------------ replies */ function sendJson(res, status, value) { const body = JSON.stringify(value); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }); res.end(body); } function sendPage(res, file, status = 200) { fs.readFile(path.join(PUBLIC_DIR, file), (err, body) => { if (err) return sendText(res, 500, 'Could not read page.'); res.writeHead(status, { 'Content-Type': MIME['.html'], 'Content-Length': body.length, 'Cache-Control': 'no-store', }); res.end(body); }); } function sendText(res, status, message) { const body = `${message}\n`; res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body), }); res.end(body); } function sendCsv(res, filename, csv) { const body = Buffer.from(csv, 'utf8'); res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Length': body.length, 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-store', }); res.end(body); } function sendStatic(res, name) { const file = path.join(PUBLIC_DIR, name); // Never let a crafted path climb out of public/. if (!file.startsWith(PUBLIC_DIR + path.sep)) return sendText(res, 403, 'Forbidden'); fs.readFile(file, (err, body) => { if (err) return sendText(res, 404, 'Not found'); res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Content-Length': body.length, 'Cache-Control': 'no-cache', }); res.end(body); }); } function readBody(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', (chunk) => { raw += chunk; // Nothing here should ever be large; refuse rather than buffer forever. if (raw.length > 256 * 1024) reject(new Error('Request too large.')); }); req.on('error', reject); req.on('end', () => { if (!raw) return resolve({}); try { resolve(JSON.parse(raw)); } catch { reject(new Error('Expected JSON.')); } }); }); } /* ------------------------------------------------------------------- router */ // Resolve a slug to an event, replying for the caller if it is gone. function requireEvent(res, slug, asJson) { const event = store.loadEvent(slug); if (event) return event; if (store.isExpired(slug)) { if (asJson) sendJson(res, 410, { error: 'This event has expired and its data has been deleted.' }); else sendPage(res, 'expired.html', 410); } else if (asJson) { sendJson(res, 404, { error: 'No such event.' }); } else { sendPage(res, 'notfound.html', 404); } return null; } async function route(req, res) { const url = new URL(req.url, 'http://localhost'); const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); const method = req.method; /* pages and assets */ if (method === 'GET' && parts.length === 0) return sendPage(res, 'index.html'); if (method === 'GET' && parts.length === 1 && parts[0] === 'new') return sendPage(res, 'new.html'); if (method === 'GET' && parts[0] === 'static' && parts.length === 2) return sendStatic(res, parts[1]); /* all-events CSV */ if (method === 'GET' && parts.length === 1 && parts[0] === 'export.csv') { return sendCsv(res, 'repair-cafe-all-events.csv', toCsv(store.listEvents())); } /* api */ if (parts[0] === 'api') { if (method === 'GET' && parts[1] === 'config' && parts.length === 2) { const { categories, houseRules, retentionDays } = store.getConfig(); return sendJson(res, 200, { categories, houseRules, retentionDays }); } if (method === 'POST' && parts[1] === 'events' && parts.length === 2) { const body = await readBody(req); const event = store.createEvent(body.name, body.randomize === true); return sendJson(res, 201, { slug: event.slug, name: event.name }); } if (parts[1] === 'events' && parts.length >= 3) { const slug = parts[2]; if (method === 'GET' && parts.length === 3) { const event = requireEvent(res, slug, true); if (!event) return; return sendJson(res, 200, { ...event, expiresAt: store.expiresAt(event) }); } if (method === 'POST' && parts[3] === 'repairs' && parts.length === 4) { const event = requireEvent(res, slug, true); if (!event) return; return sendJson(res, 201, store.addRepair(event, await readBody(req))); } if (method === 'PATCH' && parts[3] === 'repairs' && parts.length === 5) { const event = requireEvent(res, slug, true); if (!event) return; const repair = store.updateRepair(event, parts[4], await readBody(req)); if (!repair) return sendJson(res, 404, { error: 'No such repair.' }); return sendJson(res, 200, repair); } } return sendJson(res, 404, { error: 'No such endpoint.' }); } /* event pages */ if (method === 'GET' && parts.length >= 1 && parts.length <= 2) { const slug = parts[0]; if (slug !== store.slugify(slug)) return sendPage(res, 'notfound.html', 404); if (parts.length === 1) { if (!requireEvent(res, slug, false)) return; return sendPage(res, 'intake.html'); } if (parts[1] === 'dashboard') { if (!requireEvent(res, slug, false)) return; return sendPage(res, 'dashboard.html'); } if (parts[1] === 'export.csv') { const event = requireEvent(res, slug, false); if (!event) return; return sendCsv(res, `${slug}.csv`, toCsv([event])); } } return sendPage(res, 'notfound.html', 404); } /* ------------------------------------------------------------------- server */ const server = http.createServer((req, res) => { route(req, res).catch((err) => { // Validation failures from the store are the user's to fix; anything else // is ours, so log it. const isValidation = err instanceof Error && !!err.message && err.message.length < 300; if (!isValidation) console.error(err); if (res.headersSent) return res.end(); if (req.url.startsWith('/api/')) sendJson(res, 400, { error: err.message || 'Something went wrong.' }); else sendText(res, 400, err.message || 'Something went wrong.'); }); }); function start() { const config = store.loadConfig(); const pruned = store.pruneExpired(); if (pruned.length) console.log(`Deleted ${pruned.length} expired event(s): ${pruned.join(', ')}`); // Re-check daily for long-running servers. unref() so this timer alone never // keeps the process alive. setInterval(() => { const gone = store.pruneExpired(); if (gone.length) console.log(`Deleted ${gone.length} expired event(s): ${gone.join(', ')}`); }, DAY_MS).unref(); const port = Number(process.env.PORT) || config.port || 8080; const host = process.env.HOST || '0.0.0.0'; server.listen(port, host, () => { console.log(`Repair Cafe Kanban listening on http://localhost:${port}`); console.log( config.retentionDays > 0 ? `Events are deleted ${config.retentionDays} days after they are created.` : 'Event retention is off — nothing is deleted automatically.' ); }); } if (require.main === module) start(); module.exports = { server, start };