Robuster Bild-Proxy: Retries + persistenter Disk-Cache auf /data (Bilder dauerhaft verfügbar)

This commit is contained in:
2026-06-04 09:00:57 +00:00
parent 0fa8478319
commit 92e479a1c4
+62 -54
View File
@@ -1,8 +1,11 @@
// Urlaubsvergleich 2026 Familie Heidrich // 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 http = require('http');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 80;
const PUBLIC = path.join(__dirname, 'public'); const PUBLIC = path.join(__dirname, 'public');
@@ -18,6 +21,8 @@ function pickDataDir() {
} }
const DATA_DIR = pickDataDir(); const DATA_DIR = pickDataDir();
const DATA_FILE = path.join(DATA_DIR, 'votes.json'); 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']; const VOTERS = ['till', 'lea', 'astrid'];
@@ -30,22 +35,56 @@ function saveState(state) {
} }
let state = loadState(); let state = loadState();
// In-memory image cache for the proxy // ---------- Image proxy with retry + memory + disk cache ----------
const imgCache = new Map(); const imgCache = new Map();
function hostAllowed(host) { function hostAllowed(host) {
return /(^|\.)urlaub\.check24\.de$/.test(host) || host === 'files.ahoi-schiff.de'; 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', 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', '.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' }; '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json' };
function sendJSON(res, code, obj) { function sendJSON(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(body); res.end(JSON.stringify(obj));
} }
function readBody(req) { function readBody(req) {
return new Promise((resolve) => { return new Promise((resolve) => {
let b = ''; req.on('data', c => { b += c; if (b.length > 1e5) req.destroy(); }); 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; const p = url.pathname;
// ---- API ---- // ---- API ----
if (p === '/api/state' && req.method === 'GET') { if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
}
if (p === '/api/vote' && req.method === 'POST') { if (p === '/api/vote' && req.method === 'POST') {
const { voter, option, stars } = await readBody(req); 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' }); return sendJSON(res, 400, { error: 'invalid' });
}
state.votes[voter] = state.votes[voter] || {}; state.votes[voter] = state.votes[voter] || {};
if (stars === 0) { delete state.votes[voter][option]; } if (stars === 0) delete state.votes[voter][option]; else state.votes[voter][option] = Math.round(stars);
else { state.votes[voter][option] = Math.round(stars); }
saveState(state); saveState(state);
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS }); return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
} }
@@ -76,34 +111,17 @@ const server = http.createServer(async (req, res) => {
state = { votes: {} }; saveState(state); state = { votes: {} }; saveState(state);
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS }); 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') { if (p === '/img' && req.method === 'GET') {
const u = url.searchParams.get('u'); const u = url.searchParams.get('u');
let host; let host; try { host = new URL(u).host; } catch (e) { res.writeHead(400); return res.end('bad url'); }
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 (!hostAllowed(host)) { res.writeHead(403); return res.end('host not allowed'); }
if (imgCache.has(u)) { const img = await getImage(u);
const c = imgCache.get(u); if (!img) { res.writeHead(502); return res.end('image unavailable'); }
res.writeHead(200, { 'Content-Type': c.type, 'Cache-Control': 'public, max-age=604800' }); res.writeHead(200, { 'Content-Type': img.type, 'Cache-Control': 'public, max-age=2592000' });
return res.end(c.buf); return res.end(img.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 ---- // ---- Static ----
@@ -111,34 +129,24 @@ const server = http.createServer(async (req, res) => {
const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[\/\\])+/, '')); const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[\/\\])+/, ''));
if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('forbidden'); } if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('forbidden'); }
fs.readFile(full, (err, data) => { fs.readFile(full, (err, data) => {
if (err) { if (err) return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
// 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'); } if (e2) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': MIME['.html'] }); res.end(d2); res.writeHead(200, { 'Content-Type': MIME['.html'] }); res.end(d2);
}); });
}
const ext = path.extname(full).toLowerCase(); const ext = path.extname(full).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' }); res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(data); 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 () => { (async () => {
let list = []; let list = [];
try { list = JSON.parse(fs.readFileSync(path.join(__dirname, 'warm-urls.json'), 'utf8')); } try { list = JSON.parse(fs.readFileSync(path.join(__dirname, 'warm-urls.json'), 'utf8')); } catch (e) { return; }
catch (e) { return; } let ok = 0;
for (const u of list) { for (const u of list) { if (await getImage(u)) ok++; }
try { console.log('Bild-Cache vorgewärmt: ' + ok + '/' + list.length);
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);
})(); })();