// Immich upstream config for the officer-photos sidecar. // // All knowledge of the Immich instance — its URL and its API key — lives here. The platform API is a thin // auth+forward proxy and holds NO Immich credentials. // // The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `photos_config` (see // databases/officer_db/src/photos/queries.ts). It is deliberately no longer read from the environment: // Bun auto-loads `.env` into every process started in the platform directory, so an `IMMICH_API_KEY` there // was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no code // to use it and the largest attack surface in the system. Nothing in this file reads process.env. // // Two things about Immich's API are load-bearing: // // 1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a // 403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad // credential. Create the key with all permissions unless there is a reason not to. // 2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative // credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is // exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and // nothing below adds them; the platform proxy forwards only content-type, range and if-none-match. import { getActivePhotosCredentials } from '../db/queries'; /** Everything needed to make one call. A candidate being validated has this and nothing else yet. */ export type UpstreamTarget = { base: string; key: string }; /** * A stored account, which is where every real call goes. * * `id` and `label` ride along because the owner can have several accounts registered and only one selected: * a probe has to be recorded against the row it actually reached, and a log line saying which library * answered is the difference between "photos is broken" and "you are looking at the other account". */ export type UpstreamConfig = UpstreamTarget & { id: number; label: string }; /** Trailing slashes off, so `${base}/api/...` never doubles the separator. */ export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); // A thumbnail grid is a hundred requests in a second and each one needs the key, so the row is cached rather // than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in // psql, which then takes effect within a minute instead of needing a restart. const TTL_MS = 60_000; const cache = new Map(); /** * The owner's SELECTED account, or null when /photos has no account yet — the sidecar then answers 503, * and the UI turns that into the setup form rather than a wall of empty grids. */ export async function getConfig(userId: number): Promise { const hit = cache.get(userId); if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; const creds = await getActivePhotosCredentials(userId); const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : null; cache.set(userId, { cfg, at: Date.now() }); return cfg; } /** Drop the cached row — called by the config routes after any add, edit, switch or removal. */ export function invalidateConfig(userId: number): void { cache.delete(userId); } /** Carries the upstream status so callers can tell a rejected credential from an unreachable instance. */ export class UpstreamError extends Error { constructor( message: string, readonly status: number, ) { super(message); } } /** Immich errors as `{ message }`; fall back to the status when the body is not that (a proxy's HTML, say). */ async function upstreamMessage(res: Response, fallback: string): Promise { const body = (await res.json().catch(() => null)) as { message?: unknown } | null; const message = typeof body?.message === 'string' ? body.message : ''; return message ? `${message} (${res.status})` : `${fallback} with ${res.status}`; } /** * Trade an email and password for a SESSION token. * * A session is not an API key and the difference is load-bearing in both directions: sessions are listed as * devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather than the * `x-api-key` header everything else here uses — but they are also the ONLY credential Immich will elevate. * `POST /auth/session/unlock` and `POST /sessions` both open with `if (!auth.session) throw` (verified against * the running 3.1.0 image), so an API key can never reach the locked folder and can never mint a session to * get there. That is why this is exported rather than inlined into mintApiKey. * * The password is used here and nowhere else. Callers hold the returned token or discard it; nothing stores it. */ export async function login(base: string, email: string, password: string): Promise { const res = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), redirect: 'manual', }); // Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus // "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them. if (!res.ok) throw new UpstreamError(await upstreamMessage(res, 'sign-in failed'), res.status); const session = (await res.json()) as { accessToken?: unknown }; if (typeof session.accessToken !== 'string' || !session.accessToken) { throw new UpstreamError('sign-in returned no access token', res.status); } return session.accessToken; } /** * End a session. Best effort by design: a session we cannot close is untidy, not a failure of whatever the * caller was actually doing. Skipping it entirely would leave a phantom "device" in the owner's Immich * account for every sign-in Officer makes. */ export async function logout(base: string, token: string): Promise { await fetch(`${base}/api/auth/logout`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, redirect: 'manual', }).catch(() => {}); } /** * Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own * settings screen. * * `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only * grantable because a session has every permission — an API key can never mint a key stronger than itself * (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys are scoped, * and an under-scoped one returns 403 per route, which reads as a broken feature. * * The session exists only to hold the key and is closed in the `finally`. The password is used here and * nowhere else; only the key survives this function. */ export async function mintApiKey(base: string, email: string, password: string): Promise { const accessToken = await login(base, email, password); const bearer = { Authorization: `Bearer ${accessToken}` }; try { const created = await fetch(`${base}/api/api-keys`, { method: 'POST', headers: { ...bearer, Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Officer', permissions: ['all'] }), redirect: 'manual', }); if (!created.ok) { throw new UpstreamError(await upstreamMessage(created, 'could not create an API key'), created.status); } const payload = (await created.json()) as { secret?: unknown }; if (typeof payload.secret !== 'string' || !payload.secret) { throw new UpstreamError('Immich created a key but returned no secret', created.status); } return payload.secret; } finally { await logout(base, accessToken); } } type CallOptions = { /** Absolute path on the Immich host, e.g. `/api/albums`. */ path: string; method?: string; /** Raw search string including the leading `?`, or empty. */ query?: string; /** * A string for the JSON callers, or the forwarder's already-buffered request body. * * This used to be a stream, for the obvious reason that a phone's video should not sit in this * process's heap. It was changed back deliberately: a request body still being written when Immich * answers loses the answer entirely — Node replaces the app's response with a bodyless 400 and closes * — which is what made every large upload fail inexplicably. `readBodyCapped` in routes.ts has the * measurements and the alternatives that were tried; the heap cost is the accepted price. * * A buffer also carries a Content-Length, so the request to Immich is no longer chunked. */ body?: BodyInit | null; contentType?: string | null; /** Byte range for thumbnail/original/video reads, forwarded verbatim. */ range?: string | null; ifNoneMatch?: string | null; /** Set false for the version/ping routes, which Immich serves unauthenticated. */ withKey?: boolean; /** * A session token to authenticate as, INSTEAD of the stored API key. * * Only the locked-folder surface sets this (see locked.ts). Elevation lives on a session, so a request that * must see `visibility: 'locked'` assets has to carry one; everything else stays on the key. The two are * mutually exclusive on purpose — sending both would leave which credential Immich actually used up to its * header precedence rather than to this file. */ bearer?: string; }; /** The single door to Immich. Everything the sidecar fetches goes through here. */ export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise { const headers: Record = { Accept: 'application/json' }; if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`; else if (opts.withKey !== false) headers['x-api-key'] = cfg.key; if (opts.contentType) headers['Content-Type'] = opts.contentType; if (opts.range) headers.Range = opts.range; if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch; // `duplex: 'half'` is required by the fetch spec whenever the body is a stream, and is absent from // TypeScript's RequestInit — hence the widened type, same as createSidecarProxy does. Harmless for the // string bodies. const init: RequestInit & { duplex?: 'half' } = { method: opts.method ?? 'GET', headers, body: opts.body ?? undefined, duplex: 'half', // A redirect from an API route means something has gone wrong with auth; surface it rather than // following it into an HTML page. redirect: 'manual', }; const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, init); if (res.status === 401 || res.status === 403) { // 403 is the interesting one: the key is valid but lacks the permission this route needs. console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (key rejected or under-scoped)`); } return res; }