// 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(); const UPSTREAM_HOSTS = [ /(^|\.)travelapi\.com$/, /(^|\.)hotelbeds\.com$/, /(^|\.)paximum\.com$/, /(^|\.)hotelston\.com$/, /(^|\.)sunhotels\.net$/, /(^|\.)giatamedia\.com$/, /(^|\.)tui\.com$/, /(^|\.)bstatic\.com$/, /(^|\.)dnatatravel\.com$/ ]; function isCheck24(host) { return /(^|\.)urlaub\.check24\.de$/.test(host); } function hostAllowed(host) { return isCheck24(host) || host === 'files.ahoi-schiff.de' || UPSTREAM_HOSTS.some(re => re.test(host)); } // Check24 wraps the real image URL (base64) inside its CDN path. Their CDN blocks // server-side hotlinking, but the underlying source CDNs (travelapi, hotelbeds, …) // serve freely — so we unwrap and fetch the original directly. function unwrap(u) { try { const m = u.match(/source=([^!\/]+)/); if (m) { const real = Buffer.from(m[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); if (/^https?:\/\//.test(real)) return real; } } catch (e) {} return u; } 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 host0; try { host0 = new URL(u).host; } catch (e) { return null; } // If it's a Check24 CDN wrapper, fetch the original source URL directly. const target = isCheck24(host0) ? unwrap(u) : u; let thost; try { thost = new URL(target).host; } catch (e) { thost = host0; } const referer = thost.endsWith('ahoi-schiff.de') ? 'https://www.ahoi-schiff.de/' : ('https://' + thost + '/'); for (let i = 0; i < tries; i++) { try { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 9000); const r = await fetch(target, { signal: ac.signal, headers: { 'Referer': referer, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', 'Accept': 'image/avif,image/webp,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); })();