Files
holiday-2026/server.js
T

153 lines
6.6 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 the page, a voting API (file-persisted),
// and a robust same-origin image proxy (retries + disk cache) so hotel/cruise
// photos always load regardless of third-party hotlink protection / CDN hiccups.
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
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 IMG_DIR = path.join(DATA_DIR, 'imgcache');
try { fs.mkdirSync(IMG_DIR, { recursive: true }); } catch (e) {}
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();
// ---------- Image proxy with retry + memory + disk cache ----------
const imgCache = new Map();
function hostAllowed(host) {
return /(^|\.)urlaub\.check24\.de$/.test(host) || host === 'files.ahoi-schiff.de';
}
const keyFor = u => crypto.createHash('sha1').update(u).digest('hex');
function diskGet(u) {
const f = path.join(IMG_DIR, keyFor(u));
try { if (fs.existsSync(f) && fs.existsSync(f + '.t')) return { buf: fs.readFileSync(f), type: fs.readFileSync(f + '.t', 'utf8') }; }
catch (e) {}
return null;
}
function diskPut(u, buf, type) {
const f = path.join(IMG_DIR, keyFor(u));
try { fs.writeFileSync(f, buf); fs.writeFileSync(f + '.t', type); } catch (e) {}
}
async function fetchImg(u, tries = 3) {
let host; try { host = new URL(u).host; } catch (e) { return null; }
const referer = host.endsWith('ahoi-schiff.de') ? 'https://www.ahoi-schiff.de/' : 'https://urlaub.check24.de/';
for (let i = 0; i < tries; i++) {
try {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 9000);
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) return { buf: Buffer.from(await r.arrayBuffer()), type: r.headers.get('content-type') || 'image/jpeg' };
} catch (e) { /* retry */ }
await new Promise(res => setTimeout(res, 400 * (i + 1)));
}
return null;
}
async function getImage(u) {
if (imgCache.has(u)) return imgCache.get(u);
const d = diskGet(u);
if (d) { if (imgCache.size < 400) imgCache.set(u, d); return d; }
const f = await fetchImg(u);
if (f) { if (imgCache.size < 400) imgCache.set(u, f); diskPut(u, f.buf, f.type); return f; }
return null;
}
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) {
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(obj));
}
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, imgCached: imgCache.size });
// ---- Image proxy ----
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'); }
const img = await getImage(u);
if (!img) { res.writeHead(502); return res.end('image unavailable'); }
res.writeHead(200, { 'Content-Type': img.type, 'Cache-Control': 'public, max-age=2592000' });
return res.end(img.buf);
}
// ---- 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) 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 auf Port ' + PORT + ' | Daten: ' + DATA_FILE + ' | Bilder: ' + IMG_DIR));
// Warm the image cache on boot (retries + disk persist) so the first visit is instant
// and images survive redeploys.
(async () => {
let list = [];
try { list = JSON.parse(fs.readFileSync(path.join(__dirname, 'warm-urls.json'), 'utf8')); } catch (e) { return; }
let ok = 0;
for (const u of list) { if (await getImage(u)) ok++; }
console.log('Bild-Cache vorgewärmt: ' + ok + '/' + list.length);
})();