331 lines
13 KiB
JavaScript
331 lines
13 KiB
JavaScript
const http = require('http');
|
|
const net = require('net');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
const IMMICH_URL = process.env.IMMICH_URL || 'http://immich-server:2283';
|
|
const immichParsed = new URL(IMMICH_URL);
|
|
|
|
// Timeouts for large uploads (10 minutes)
|
|
const UPLOAD_TIMEOUT = 10 * 60 * 1000;
|
|
|
|
// Load the custom JS to inject. Version hash lets PWAs detect stale caches.
|
|
const customJS = fs.readFileSync(path.join(__dirname, 'live-search.js'), 'utf8');
|
|
const CUSTOM_JS_HASH = crypto
|
|
.createHash('sha256')
|
|
.update(customJS)
|
|
.digest('hex')
|
|
.slice(0, 12);
|
|
|
|
// `currentVersion` combines our custom script with a fingerprint of Immich's
|
|
// served HTML so that any upstream update (chunk-hash rotation, etc.) also
|
|
// bumps the version. Stuck PWAs polling /api/custom/inject-version will see
|
|
// the change and self-reload. Initialized with just the custom hash until the
|
|
// first HTML response lands.
|
|
let currentVersion = CUSTOM_JS_HASH;
|
|
console.log(`Injected script hash: ${CUSTOM_JS_HASH} (initial version: ${currentVersion})`);
|
|
|
|
function makeScriptTag(version) {
|
|
return (
|
|
`<script>window.__LS_VERSION=${JSON.stringify(version)};</script>` +
|
|
`<script>${customJS}</script>`
|
|
);
|
|
}
|
|
|
|
// Extract just the chunk/manifest references from Immich's HTML (the parts
|
|
// that actually change on upgrades). Hashing the full HTML would include
|
|
// server-rendered state (CSRF tokens, session user id) that change per-user
|
|
// per-request and would cause spurious version churn.
|
|
function extractShellFingerprint(html) {
|
|
const matches = html.match(/\/_app\/immutable\/[^"'\s]+/g) || [];
|
|
return matches.sort().join('|');
|
|
}
|
|
|
|
// Helper: make an internal request to Immich (for small JSON requests only)
|
|
function immichRequest(method, apiPath, headers, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(apiPath, IMMICH_URL);
|
|
const opts = {
|
|
hostname: url.hostname,
|
|
port: url.port || 80,
|
|
path: url.pathname + url.search,
|
|
method,
|
|
headers: { ...headers, host: url.host }
|
|
};
|
|
delete opts.headers['accept-encoding'];
|
|
|
|
const req = http.request(opts, (res) => {
|
|
let chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => {
|
|
const data = Buffer.concat(chunks).toString('utf8');
|
|
resolve({ status: res.statusCode, headers: res.headers, body: data });
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
if (body) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Standalone recovery page: unregister service workers, clear all caches,
|
|
// clear storage, and reload. Use when a PWA is stuck on a broken cached
|
|
// chunk or service worker state.
|
|
const RESET_HTML = `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>Resetting…</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; background:#111; color:#eee;
|
|
display:flex; align-items:center; justify-content:center;
|
|
height:100vh; margin:0; flex-direction:column; gap:16px; padding:20px; }
|
|
.spinner { width:40px; height:40px; border:4px solid #333;
|
|
border-top-color:#e94560; border-radius:50%;
|
|
animation: spin 0.8s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.status { font-size:15px; text-align:center; max-width:320px; }
|
|
a { color:#e94560; }
|
|
</style>
|
|
</head><body>
|
|
<div class="spinner"></div>
|
|
<div class="status" id="status">Clearing caches…</div>
|
|
<script>
|
|
(async function(){
|
|
const s = document.getElementById('status');
|
|
const log = (m) => { s.textContent = m; };
|
|
try {
|
|
if ('serviceWorker' in navigator) {
|
|
log('Unregistering service workers…');
|
|
const regs = await navigator.serviceWorker.getRegistrations();
|
|
await Promise.all(regs.map(r => r.unregister().catch(() => null)));
|
|
}
|
|
if (window.caches) {
|
|
log('Clearing caches…');
|
|
const keys = await caches.keys();
|
|
await Promise.all(keys.map(k => caches.delete(k).catch(() => null)));
|
|
}
|
|
try { localStorage.removeItem('ls-heatmap-api-key'); } catch {}
|
|
} catch (e) {
|
|
log('Error: ' + e.message + ' — reloading anyway.');
|
|
}
|
|
log('Done. Reloading…');
|
|
setTimeout(() => {
|
|
location.replace('/?_ts=' + Date.now());
|
|
}, 500);
|
|
})();
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
// --- Recovery page: /__reset ---
|
|
if ((req.url === '/__reset' || req.url.startsWith('/__reset?')) && req.method === 'GET') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
// Clear-Site-Data tells the browser itself to drop cookies,
|
|
// cached responses, local storage, and SW registrations for
|
|
// this origin — far more thorough than the JS-side caches.delete.
|
|
'Clear-Site-Data': '"cache", "storage", "executionContexts"',
|
|
'Service-Worker-Allowed': '/'
|
|
});
|
|
res.end(RESET_HTML);
|
|
return;
|
|
}
|
|
|
|
// --- Custom endpoint: report current injected-script version ---
|
|
if (req.url === '/api/custom/inject-version' && req.method === 'GET') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate'
|
|
});
|
|
res.end(JSON.stringify({ version: currentVersion }));
|
|
return;
|
|
}
|
|
|
|
// --- Custom endpoint: batch fetch exif (needs buffered body) ---
|
|
if (req.url === '/api/custom/batch-exif' && req.method === 'POST') {
|
|
let bodyChunks = [];
|
|
req.on('data', chunk => bodyChunks.push(chunk));
|
|
req.on('end', async () => {
|
|
const body = Buffer.concat(bodyChunks);
|
|
try {
|
|
const { ids } = JSON.parse(body.toString());
|
|
const authHeader = req.headers['authorization'] || '';
|
|
const apiKey = req.headers['x-api-key'] || '';
|
|
const hdrs = { 'Content-Type': 'application/json' };
|
|
if (authHeader) hdrs['Authorization'] = authHeader;
|
|
if (apiKey) hdrs['x-api-key'] = apiKey;
|
|
|
|
const results = await Promise.all(ids.map(async (id) => {
|
|
try {
|
|
const r = await immichRequest('GET', `/api/assets/${id}`, hdrs);
|
|
if (r.status === 200) {
|
|
const asset = JSON.parse(r.body);
|
|
const exif = asset.exifInfo || {};
|
|
return {
|
|
id,
|
|
latitude: exif.latitude || null,
|
|
longitude: exif.longitude || null,
|
|
city: exif.city || null,
|
|
state: exif.state || null,
|
|
country: exif.country || null
|
|
};
|
|
}
|
|
} catch {}
|
|
return { id, latitude: null, longitude: null };
|
|
}));
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(results));
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// --- Standard streaming proxy ---
|
|
const targetUrl = new URL(req.url, IMMICH_URL);
|
|
const headers = { ...req.headers };
|
|
headers.host = targetUrl.host;
|
|
delete headers['accept-encoding'];
|
|
|
|
const proxyOpts = {
|
|
hostname: targetUrl.hostname,
|
|
port: targetUrl.port || 80,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers,
|
|
timeout: UPLOAD_TIMEOUT
|
|
};
|
|
|
|
const proxyReq = http.request(proxyOpts, (proxyRes) => {
|
|
const contentType = proxyRes.headers['content-type'] || '';
|
|
const isHTML = contentType.includes('text/html');
|
|
|
|
if (isHTML) {
|
|
// Buffer HTML only to inject script tag
|
|
let htmlChunks = [];
|
|
proxyRes.on('data', chunk => htmlChunks.push(chunk));
|
|
proxyRes.on('end', () => {
|
|
let html = Buffer.concat(htmlChunks).toString('utf8');
|
|
|
|
// Compute a per-response version: custom script hash XOR'd
|
|
// with the upstream HTML's chunk/manifest fingerprint. This
|
|
// way an Immich upgrade that rotates chunk hashes bumps our
|
|
// version and stuck PWAs will self-heal on the next poll.
|
|
const shellHash = crypto
|
|
.createHash('sha256')
|
|
.update(customJS)
|
|
.update(extractShellFingerprint(html))
|
|
.digest('hex')
|
|
.slice(0, 12);
|
|
currentVersion = shellHash;
|
|
|
|
html = html.replace('</body>', makeScriptTag(shellHash) + '</body>');
|
|
|
|
const resHeaders = { ...proxyRes.headers };
|
|
delete resHeaders['content-length'];
|
|
delete resHeaders['content-encoding'];
|
|
resHeaders['content-type'] = 'text/html; charset=utf-8';
|
|
// Prevent browser + CDN from caching our modified HTML so the
|
|
// inline script version check stays fresh on every navigation.
|
|
resHeaders['cache-control'] = 'no-store, no-cache, must-revalidate';
|
|
delete resHeaders['etag'];
|
|
delete resHeaders['last-modified'];
|
|
// Drop CSP/XFO so we can iframe heatmap.jeffemmett.com and
|
|
// inject arbitrary elements into pages.
|
|
delete resHeaders['content-security-policy'];
|
|
delete resHeaders['content-security-policy-report-only'];
|
|
delete resHeaders['x-frame-options'];
|
|
|
|
res.writeHead(proxyRes.statusCode, resHeaders);
|
|
res.end(html);
|
|
});
|
|
} else {
|
|
// Stream non-HTML responses directly (videos, images, API JSON)
|
|
const streamHeaders = { ...proxyRes.headers };
|
|
// Don't let browsers or CDNs cache error responses — a 5xx
|
|
// that sneaks into a long-lived cache entry can brick the PWA.
|
|
if (proxyRes.statusCode >= 500) {
|
|
streamHeaders['cache-control'] = 'no-store, no-cache, must-revalidate';
|
|
delete streamHeaders['expires'];
|
|
delete streamHeaders['etag'];
|
|
delete streamHeaders['last-modified'];
|
|
}
|
|
res.writeHead(proxyRes.statusCode, streamHeaders);
|
|
proxyRes.pipe(res);
|
|
}
|
|
});
|
|
|
|
proxyReq.on('error', (e) => {
|
|
console.error('Proxy error:', e.message);
|
|
if (!res.headersSent) {
|
|
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
res.end('Bad Gateway');
|
|
}
|
|
});
|
|
|
|
proxyReq.on('timeout', () => {
|
|
console.error('Proxy request timed out');
|
|
proxyReq.destroy();
|
|
if (!res.headersSent) {
|
|
res.writeHead(504, { 'Content-Type': 'text/plain' });
|
|
res.end('Gateway Timeout');
|
|
}
|
|
});
|
|
|
|
// Stream request body directly to Immich (no buffering)
|
|
req.pipe(proxyReq);
|
|
});
|
|
|
|
// WebSocket proxy — handle HTTP upgrade events
|
|
server.on('upgrade', (req, clientSocket, head) => {
|
|
const targetHost = immichParsed.hostname;
|
|
const targetPort = immichParsed.port || 80;
|
|
|
|
const proxySocket = net.connect(targetPort, targetHost, () => {
|
|
// Reconstruct the HTTP upgrade request to forward
|
|
const reqHeaders = [`${req.method} ${req.url} HTTP/1.1`];
|
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
if (key.toLowerCase() === 'host') {
|
|
reqHeaders.push(`Host: ${targetHost}:${targetPort}`);
|
|
} else {
|
|
reqHeaders.push(`${key}: ${value}`);
|
|
}
|
|
}
|
|
reqHeaders.push('', '');
|
|
|
|
proxySocket.write(reqHeaders.join('\r\n'));
|
|
if (head.length > 0) proxySocket.write(head);
|
|
|
|
// Bidirectional pipe
|
|
proxySocket.pipe(clientSocket);
|
|
clientSocket.pipe(proxySocket);
|
|
});
|
|
|
|
proxySocket.on('error', (e) => {
|
|
console.error('WebSocket proxy error:', e.message);
|
|
clientSocket.destroy();
|
|
});
|
|
|
|
clientSocket.on('error', (e) => {
|
|
console.error('WebSocket client error:', e.message);
|
|
proxySocket.destroy();
|
|
});
|
|
});
|
|
|
|
// Increase server timeouts for large uploads
|
|
server.timeout = UPLOAD_TIMEOUT;
|
|
server.requestTimeout = UPLOAD_TIMEOUT;
|
|
server.headersTimeout = UPLOAD_TIMEOUT + 1000;
|
|
server.keepAliveTimeout = UPLOAD_TIMEOUT;
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`Immich proxy with live search running on port ${PORT}`);
|
|
console.log(`Proxying to: ${IMMICH_URL}`);
|
|
console.log(`Upload timeout: ${UPLOAD_TIMEOUT / 1000}s`);
|
|
});
|