import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { handleConfigRoute, noteProbe, probe } from './config'; import { handleLockedRoute } from './locked'; import { handleOfficerRoute } from './routes'; import { getConfig } from './upstream'; import { API_URL } from '@@/officer-url.mjs'; // The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key. // The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich // credentials. // // The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no // longer read from the environment. See upstream.ts for why that move mattered. // // Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation // behind it. The route surface below is Officer's, so swapping the backend would not move the mount point. // // Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated). // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding. // // GET /_health ours. Confirms the key is live; reports the Immich version and who the key is. // GET /_config the account registry MINUS every key: { configured, activeId, accounts[] } // POST /_config { label?, url, and EITHER apiKey OR email+password } — the password mints a // key and is never stored. Validated against the instance, then stored encrypted // PATCH /_config/:id same fields, all optional — no credential keeps the stored key // POST /_config/:id/activate switch to that account // DEL /_config/:id remove it; the newest survivor is promoted if it was the active one // * /_officer/ forwarded to /api/, first-segment allow-list (routes.ts) // // GET /_locked/status { hasPin, hasPassword, unlocked, expiresAt } — the private folder's state // POST /_locked/pin { pinCode } set one up; PUT changes it, DELETE removes it (API key is enough) // POST /_locked/unlock { password, pinCode } — signs in, elevates, holds the session IN MEMORY // POST /_locked/lock drops elevation and closes the session // * /_locked/api/ forwarded on that session — assets, search and timeline only (locked.ts) // // anything else 404 // // Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind // loopback only, so its presence is the trust signal — a request without it did not come through the // platform, and the connection is per-owner data. // // So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and // `/api/photos/_officer/assets//thumbnail?size=preview` streams the thumbnail bytes back, Range and // ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list. // ───────────────────────────────────────────────────────────────────────────────────────────────── /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const server = Bun.serve({ port, hostname: '127.0.0.1', // Uploads go through this proxy as multipart bodies, and a phone's video is not small. maxRequestBodySize: 4 * 1024 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { try { return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); } catch (err) { console.error(`[photos] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } const cfg = await getConfig(userId); // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a // configured-but-broken instance is the whole reason the flag is on the response. if (url.pathname === '/_health') { if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); const started = Date.now(); const result = await probe(cfg); const ms = Date.now() - started; if (!result.ok) { return Response.json( { ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms }, { status: 502 }, ); } await noteProbe(userId, cfg.id, result.version); return Response.json({ ok: true, configured: true, account: cfg.label, version: result.version, user: result.user, ms, }); } if (url.pathname.startsWith('/_officer/')) { if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 }); try { const res = await handleOfficerRoute(cfg, req, url); if (res) return res; return Response.json({ error: 'not found' }, { status: 404 }); } catch (err) { console.error(`[photos] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } if (url.pathname === '/_locked' || url.pathname.startsWith('/_locked/')) { if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 }); try { return await handleLockedRoute(req, cfg, userId, url.pathname.slice('/_locked'.length)); } catch (err) { // Never let the error text out: this surface handles a password and a PIN, and an upstream failure // carrying either into a response body or a log line is the one leak worth being paranoid about. console.error(`[photos] ${req.method} ${url.pathname} failed`); return Response.json({ error: 'internal error' }, { status: 500 }); } } return Response.json({ error: 'not found' }, { status: 404 }); }, }); console.log(`[photos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in photos_config)`); type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'photos', handles: ['photos'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'photos:server', port }); console.log(`[photos] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[photos] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));