Files

175 lines
7.7 KiB
JavaScript
Raw Permalink 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();
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);
})();