Files
holiday-2026/server.js
T

145 lines
6.1 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();
// In-memory image cache for the proxy
const imgCache = new Map();
function hostAllowed(host) {
return /(^|\.)urlaub\.check24\.de$/.test(host) || host === 'files.ahoi-schiff.de';
}
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 }); }
// ---- Image proxy (defeats hotlink/referrer protection, same-origin = always loads) ----
if (p === '/img' && req.method === 'GET') {
const u = url.searchParams.get('u');
let host;
try { host = new URL(u).host; } catch (e) { res.writeHead(400); return res.end('bad url'); }
if (!hostAllowed(host)) { res.writeHead(403); return res.end('host not allowed'); }
if (imgCache.has(u)) {
const c = imgCache.get(u);
res.writeHead(200, { 'Content-Type': c.type, 'Cache-Control': 'public, max-age=604800' });
return res.end(c.buf);
}
try {
const referer = host.endsWith('ahoi-schiff.de') ? 'https://www.ahoi-schiff.de/' : 'https://urlaub.check24.de/';
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 8000);
const r = await fetch(u, { signal: ac.signal, headers: {
'Referer': referer, 'User-Agent': 'Mozilla/5.0 (compatible; HeidrichReise/1.0)', 'Accept': 'image/*'
}});
clearTimeout(t);
if (!r.ok) { res.writeHead(502); return res.end('upstream ' + r.status); }
const type = r.headers.get('content-type') || 'image/jpeg';
const buf = Buffer.from(await r.arrayBuffer());
if (imgCache.size < 300) imgCache.set(u, { buf, type });
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'public, max-age=604800' });
return res.end(buf);
} catch (e) { res.writeHead(502); return res.end('proxy error'); }
}
// ---- 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));
// Prefetch all hotel/cruise images into the proxy cache so the first visit is instant.
(async () => {
let list = [];
try { list = JSON.parse(fs.readFileSync(path.join(__dirname, 'warm-urls.json'), 'utf8')); }
catch (e) { return; }
for (const u of list) {
try {
const host = new URL(u).host;
if (!hostAllowed(host) || imgCache.has(u)) continue;
const referer = host.endsWith('ahoi-schiff.de') ? 'https://www.ahoi-schiff.de/' : 'https://urlaub.check24.de/';
const r = await fetch(u, { headers: { 'Referer': referer, 'User-Agent': 'Mozilla/5.0 (compatible; HeidrichReise/1.0)', 'Accept': 'image/*' } });
if (r.ok) imgCache.set(u, { buf: Buffer.from(await r.arrayBuffer()), type: r.headers.get('content-type') || 'image/jpeg' });
} catch (e) { /* skip */ }
}
console.log('Bild-Cache vorgewärmt:', imgCache.size + '/' + list.length);
})();