From 92e479a1c4c4b1948fa45d92c5f18f4cea3f3cf0 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Thu, 4 Jun 2026 09:00:57 +0000 Subject: [PATCH] =?UTF-8?q?Robuster=20Bild-Proxy:=20Retries=20+=20persiste?= =?UTF-8?q?nter=20Disk-Cache=20auf=20/data=20(Bilder=20dauerhaft=20verf?= =?UTF-8?q?=C3=BCgbar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 122 +++++++++++++++++++++++++++++------------------------- 1 file changed, 65 insertions(+), 57 deletions(-) diff --git a/server.js b/server.js index 73ff63b..cf78c22 100644 --- a/server.js +++ b/server.js @@ -1,8 +1,11 @@ // Urlaubsvergleich 2026 – Familie Heidrich -// Zero-dependency Node server: serves static page + voting API with file persistence. +// 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'); @@ -18,6 +21,8 @@ function pickDataDir() { } 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']; @@ -30,22 +35,56 @@ function saveState(state) { } let state = loadState(); -// In-memory image cache for the proxy +// ---------- 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) { - const body = JSON.stringify(obj); res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(body); + 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(); }); @@ -58,17 +97,13 @@ const server = http.createServer(async (req, res) => { const p = url.pathname; // ---- API ---- - if (p === '/api/state' && req.method === 'GET') { - return sendJSON(res, 200, { votes: state.votes, voters: VOTERS }); - } + 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) { + 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); } + 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 }); } @@ -76,34 +111,17 @@ const server = http.createServer(async (req, res) => { state = { votes: {} }; saveState(state); return sendJSON(res, 200, { votes: state.votes, voters: VOTERS }); } - if (p === '/health') { return sendJSON(res, 200, { ok: true }); } + if (p === '/health') return sendJSON(res, 200, { ok: true, imgCached: imgCache.size }); - // ---- Image proxy (defeats hotlink/referrer protection, same-origin = always loads) ---- + // ---- 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'); } + 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'); } + 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 ---- @@ -111,34 +129,24 @@ const server = http.createServer(async (req, res) => { 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); - }); - } + 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 läuft auf Port ' + PORT + ' | Daten: ' + DATA_FILE)); +server.listen(PORT, () => console.log('Reise-Vergleich auf Port ' + PORT + ' | Daten: ' + DATA_FILE + ' | Bilder: ' + IMG_DIR)); -// Prefetch all hotel/cruise images into the proxy cache so the first visit is instant. +// 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; } - 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); + 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); })();