immich-docker/search-app/server.js

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`);
});