Files
holiday-2026/server.js
T

94 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Urlaubsvergleich 2026 Familie Heidrich
// Zero-dependency Node server: serves static page + voting API with file persistence.
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 80;
const PUBLIC = path.join(__dirname, 'public');
// Persistence: prefer mounted /data volume, fall back to local ./data
function pickDataDir() {
const candidates = [process.env.DATA_DIR, '/data', path.join(__dirname, 'data')].filter(Boolean);
for (const dir of candidates) {
try { fs.mkdirSync(dir, { recursive: true }); fs.accessSync(dir, fs.constants.W_OK); return dir; }
catch (e) { /* try next */ }
}
return __dirname;
}
const DATA_DIR = pickDataDir();
const DATA_FILE = path.join(DATA_DIR, 'votes.json');
const VOTERS = ['till', 'lea', 'astrid'];
function loadState() {
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
catch (e) { return { votes: {} }; }
}
function saveState(state) {
try { fs.writeFileSync(DATA_FILE, JSON.stringify(state)); } catch (e) { console.error('save failed', e); }
}
let state = loadState();
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml',
'.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json' };
function sendJSON(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(body);
}
function readBody(req) {
return new Promise((resolve) => {
let b = ''; req.on('data', c => { b += c; if (b.length > 1e5) req.destroy(); });
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (e) { resolve({}); } });
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const p = url.pathname;
// ---- API ----
if (p === '/api/state' && req.method === 'GET') {
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
}
if (p === '/api/vote' && req.method === 'POST') {
const { voter, option, stars } = await readBody(req);
if (!VOTERS.includes(voter) || !option || typeof stars !== 'number' || stars < 0 || stars > 5) {
return sendJSON(res, 400, { error: 'invalid' });
}
state.votes[voter] = state.votes[voter] || {};
if (stars === 0) { delete state.votes[voter][option]; }
else { state.votes[voter][option] = Math.round(stars); }
saveState(state);
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
}
if (p === '/api/reset' && req.method === 'POST') {
state = { votes: {} }; saveState(state);
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
}
if (p === '/health') { return sendJSON(res, 200, { ok: true }); }
// ---- Static ----
let file = p === '/' ? '/index.html' : decodeURIComponent(p);
const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[\/\\])+/, ''));
if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('forbidden'); }
fs.readFile(full, (err, data) => {
if (err) {
// SPA-ish fallback to index
return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
if (e2) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': MIME['.html'] }); res.end(d2);
});
}
const ext = path.extname(full).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(data);
});
});
server.listen(PORT, () => console.log('Reise-Vergleich läuft auf Port ' + PORT + ' | Daten: ' + DATA_FILE));