import type { UpstreamConfig } from './upstream'; import { callUpstream } from './upstream'; // The Officer-owned route surface. This is an allow-list, not a passthrough. // // Immich's API is already REST and already shaped the way a photo UI wants it, so the value this sidecar // adds is (a) holding the API key, (b) streaming the binary routes through without buffering them, and // (c) refusing to expose the administrative half of the API at all. // // The gate is the FIRST path segment plus the method. Anything whose first segment is not listed here is a // 404 before a request is made, so the deny side is closed by construction rather than by enumeration. // // DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and // password machinery — the key is the credential here, and nothing should be minting sessions through a // dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/* // // `auth/*` is STILL refused here, and locked.ts does not change that. Immich's private folder needs four auth // routes (`auth/status`, `auth/pin-code`, `auth/session/unlock`, `auth/session/lock`), and they are reached // through named endpoints on a separate `/_locked` surface that each do one thing — not by making `auth` a // resource anything may address. The distinction is the point: the decision that was taken was "the sidecar // may unlock the private folder", not "the sidecar may reach session and password machinery". // (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external // library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it. // Adding one should be a decision, not an accident. const RESOURCES: Record = { // The library itself. assets: ['GET', 'POST', 'PUT', 'DELETE'], albums: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], timeline: ['GET'], memories: ['GET', 'POST', 'PUT', 'DELETE'], people: ['GET', 'POST', 'PUT'], faces: ['GET', 'PUT'], tags: ['GET', 'POST', 'PUT', 'DELETE'], stacks: ['GET', 'POST', 'PUT', 'DELETE'], // Finding things. `search/metadata`, `search/smart` and `search/random` are POSTs with a JSON body. search: ['GET', 'POST'], duplicates: ['GET'], map: ['GET'], view: ['GET'], // Getting things out. `download/info` then `download/archive` — both POST, the archive streams a zip. download: ['POST'], // Recoverable deletes. `trash/empty` is the one irreversible route in this list; it is here because it is // the counterpart of a delete the UI can already perform, not because it is safe. trash: ['GET', 'POST'], // Sharing and social. Read-mostly, but a shared link is the point of them. 'shared-links': ['GET', 'POST', 'PATCH', 'DELETE'], activities: ['GET', 'POST', 'DELETE'], partners: ['GET'], notifications: ['GET', 'PUT', 'DELETE'], // Read-only context: who the key acts as, and what the server is. users: ['GET'], server: ['GET'], }; // Response headers worth carrying back. Content-Type and Content-Length for every response; the rest so a // thumbnail can be cached and revalidated and a video can be seeked without the proxy understanding either. const PASSTHROUGH_HEADERS = [ 'content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges', 'etag', 'last-modified', 'cache-control', ] as const; /** * Hand one Immich response back to the caller, keeping only the headers above. * * Shared with the locked-folder surface so both forwarders stream rather than buffer and agree on what a * thumbnail is allowed to carry back. 204 and 304 must not have a body attached — Bun throws if one is. */ export function relayResponse(res: Response): Response { const headers = new Headers(); for (const name of PASSTHROUGH_HEADERS) { const value = res.headers.get(name); if (value) headers.set(name, value); } const body = res.status === 204 || res.status === 304 ? null : res.body; return new Response(body, { status: res.status, headers }); } /** * The largest request body held in memory. Above this the body is STREAMED instead — never refused. * * The cap exists to bound heap, not to bound uploads: a 4 GB video must still go through. What is given * up above the cap is only the guarantee that a REJECTION carries its message, and an upload that big * which Immich rejects was going to fail either way — see `readBodyCapped`. */ const MAX_BUFFERED_BODY = 512 * 1024 * 1024; /** * Read the whole request body into memory, refusing above `MAX_BUFFERED_BODY`. * * ── Why this is buffered rather than streamed ── * * It used to stream, which is the obvious thing for an upload and was wrong for one specific reason: * when the body is still being written and Immich answers early, the response is lost. Immich rejects * an asset as soon as it has enough of the file to judge it, then closes; our writer is still going; * Node's HTTP server calls the leftover bytes a protocol violation and replaces the app's answer with a * bodyless `400 Bad Request` + `Connection: close`. The app's real message never reaches the wire and * Immich logs nothing, so the client sees an unexplained 400 and the server has no record of it. * * Measured on this machine (COMMS/UPLOAD_EMPTY_400*.md): streamed lost the response in 2 of 4 attempts * at 8 MB, 32 MB and 64 MB alike; buffered lost it in 0 of 4 at every size. It is a race, not a size * limit — its probability just approaches certainty as the body grows, which is why a phone's video * failed every time while a small photo usually got through. * * A size threshold was considered and rejected: buffering only the small bodies would leave exactly the * large uploads that always fail on the broken path. Cancelling the write once the response arrives was * tried and does not help — by the time `fetch` resolves, the teardown has already happened. * * The cost is real and was accepted deliberately: a 300 MB video is 300 MB of this process's heap for * the duration of the upload. That is the price of the upload working at all. * * ── And why there is a ceiling on it ── * * Buffering everything would mean a 4 GB video sitting in this heap (and another copy in officer's), so * above MAX_BUFFERED_BODY the body is streamed as it always was. That is not a refusal and not a * degradation of uploads: a file Immich ACCEPTS never triggers the race, because it is read to the end * before anything is answered. The only thing lost above the cap is the error message on a file Immich * rejects — and an upload that size which Immich rejects fails either way. Refusing it outright would * break the legitimate case to improve the diagnosis of the doomed one. */ async function readBodyCapped( req: Request, ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; seen: number }> { // Decided from `content-length` alone. Officer sends one whenever IT buffered, so the two hops agree // without coordinating: a body small enough for officer to hold is small enough to hold here. When the // header is absent officer streamed, which means it was over the cap there too. const declared = Number(req.headers.get('content-length') ?? ''); if (!Number.isFinite(declared) || declared <= 0 || declared > MAX_BUFFERED_BODY) return { ok: false, seen: declared }; const reader = req.body!.getReader(); const chunks: Uint8Array[] = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.length; if (total > MAX_BUFFERED_BODY) { await reader.cancel().catch(() => {}); return { ok: false, seen: total }; } chunks.push(value); } const bytes = new Uint8Array(new ArrayBuffer(total)); let at = 0; for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; } return { ok: true, bytes }; } /** * Forward one request to Immich and stream the answer back. * * The RESPONSE is still a stream, never an ArrayBuffer: a full-resolution original or a * `download/archive` zip is hundreds of megabytes and buffering it would hold all of it in the * sidecar's heap for no reason. Only the request body is held, and only for the reason above. */ export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise { const rest = url.pathname.slice('/_officer/'.length); if (!rest) return null; const [resource] = rest.split('/'); const allowedMethods = resource ? RESOURCES[resource] : undefined; if (!allowedMethods) return null; if (!allowedMethods.includes(req.method)) { return Response.json({ error: `${req.method} not allowed on ${resource}` }, { status: 405 }); } const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; // An /