immich-docker/search-app/server.js

198 lines
7.1 KiB
JavaScript

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 = `<script>${customJS}</script>`;
// 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('</body>', SCRIPT_TAG + '</body>');
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`);
});