const http = require('http'); const net = require('net'); const fs = require('fs'); const path = require('path'); 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 const customJS = fs.readFileSync(path.join(__dirname, 'live-search.js'), 'utf8'); const SCRIPT_TAG = ``; // 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(); }); } const server = http.createServer((req, res) => { // --- 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'); html = html.replace('', SCRIPT_TAG + ''); const resHeaders = { ...proxyRes.headers }; delete resHeaders['content-length']; delete resHeaders['content-encoding']; resHeaders['content-type'] = 'text/html; charset=utf-8'; res.writeHead(proxyRes.statusCode, resHeaders); res.end(html); }); } else { // Stream non-HTML responses directly (videos, images, API JSON) res.writeHead(proxyRes.statusCode, proxyRes.headers); 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`); });