import type { UpstreamConfig } from './upstream'; import { relayResponse } from './routes'; import { UpstreamError, callUpstream, login, logout } from './upstream'; // `/_locked` — Immich's private folder, which is the one part of the library the stored API key cannot reach. // // WHY THIS IS A SEPARATE SURFACE RATHER THAN A FEW MORE ENTRIES IN routes.ts // // Immich gates locked assets on ELEVATION, and elevation lives on a session. Verified against the running // 3.1.0 image rather than inferred: // // utils/access.js requireElevatedPermission → `if (!auth.session?.hasElevatedPermission) throw` // services/auth.service.js unlockSession → `if (!auth.session) throw 'only be used with a session token'` // services/session.service.js create → the same guard, so a key cannot even mint a session to escape this // repositories/access.repository.js checkOwnerAccess → `.$if(!hasElevatedPermission, visibility != locked)` // // An API key has no session, so `auth.session` is undefined and the check fails unconditionally. No key // permission, scope or allow-list entry changes that. The last of those four is the one that decides the shape // of this file: the elevation test is inside the generic owner-access check, so it covers `AssetView` and // `AssetDownload` too — a locked asset's THUMBNAIL and ORIGINAL are gated, not merely its listings. // // So reading the private folder needs a session, a session needs a password, and Officer deliberately does not // keep one (see upstream.ts: the password is used to mint a key and discarded with the stack frame). The // resolution here is that the session is minted at unlock, held IN MEMORY for the elevation window, and closed // on lock or idle. Nothing new is written to `photos_config`, so a full compromise of Officer's database still // does not open the private folder — the PIN and the password are never at rest here. // // The cost is that unlocking asks for the Immich password as well as the PIN. That is a real cost and the // alternative is a stored session token, which would make unlock PIN-only. It was left unbuilt because it is a // standing security-posture change and this is not: everything below is forgotten when the process restarts. // // The general allow-list in routes.ts still refuses `auth/*` wholesale. This file does not widen it — it // reaches four specific auth routes through named endpoints that each do one thing, which is the narrowing the // request asked for rather than a door left open for anything under `auth/`. /** What the API key alone can do, which is more than expected and worth being precise about. */ const PIN_PATH = '/api/auth/pin-code'; /** * Immich holds elevation for 15 minutes and slides it forward while a session keeps working. We do not mirror * that arithmetic — Immich stays the authority and a 401 collapses our copy — but a session nobody has used * for longer than the window can never be elevated again, so it is closed rather than left open indefinitely. */ const IDLE_MS = 20 * 60 * 1000; /** Immich's own rule, checked here so a typo is a clear message instead of a zod dump from upstream. */ const PIN_RE = /^\d{6}$/; type Elevation = { accountId: number; base: string; token: string; lastUsedAt: number }; // Keyed by Officer user. The value never leaves this module and is never persisted. const elevations = new Map(); /** Close a session upstream and forget it. Safe to call when there is nothing to forget. */ async function drop(userId: number): Promise { const current = elevations.get(userId); if (!current) return; elevations.delete(userId); await logout(current.base, current.token); } /** * The live session for this user, or null. * * Switching Immich accounts while unlocked has to invalidate it: the session belongs to the account it was * minted against, and serving the previous library's private folder after a switch would be the worst possible * version of the caching bug the config routes already guard against. */ function current(userId: number, cfg: UpstreamConfig): Elevation | null { const found = elevations.get(userId); if (!found) return null; if (found.accountId !== cfg.id || Date.now() - found.lastUsedAt > IDLE_MS) { void drop(userId); return null; } return found; } const bad = (error: string, status = 400) => Response.json({ error }, { status }); /** 423 is the signal the UI turns back into the unlock prompt; 401 would read as "signed out of Officer". */ const locked = (error = 'the private folder is locked') => Response.json({ error, unlocked: false }, { status: 423 }); type Body = { pinCode?: unknown; newPinCode?: unknown; password?: unknown }; const readBody = async (req: Request): Promise => ((await req.json().catch(() => null)) as Body | null) ?? {}; const str = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); /** Immich answers `{ message }`; anything else means we never really reached it. */ async function message(res: Response, fallback: string): Promise { const body = (await res.json().catch(() => null)) as { message?: unknown } | null; return typeof body?.message === 'string' ? body.message : fallback; } /** * The email to sign in as, taken from the key rather than from the caller. * * The client knows the password but has no reason to know which Immich account the stored key belongs to, and * asking it to send an email would let a wrong one turn a valid password into "sign-in failed". */ async function ownerEmail(cfg: UpstreamConfig): Promise { const res = await callUpstream(cfg, { path: '/api/users/me' }); if (!res.ok) throw new UpstreamError(await message(res, 'could not identify the account'), res.status); const me = (await res.json()) as { email?: unknown }; if (typeof me.email !== 'string' || !me.email) throw new UpstreamError('the account has no email', 500); return me.email; } /** * `GET /_locked/status` — whether a PIN exists, and whether this user is currently unlocked. * * Answered through the session when there is one, because only then does Immich report `isElevated` and * `pinExpiresAt` truthfully; the API key can still answer the two questions that do not involve a session. * `hasPassword` is here so a password-less OAuth account can be told that unlocking is impossible rather than * being handed a form that cannot work. */ async function status(cfg: UpstreamConfig, userId: number): Promise { const session = current(userId, cfg); const res = await callUpstream(cfg, { path: '/api/auth/status', bearer: session?.token }); if (!res.ok) return bad(await message(res, 'could not read the account status'), res.status); const info = (await res.json()) as { pinCode?: boolean; password?: boolean; isElevated?: boolean; pinExpiresAt?: string; }; // Immich disagreeing with us is Immich winning: the window lapsed underneath a session we still hold. if (session && !info.isElevated) await drop(userId); return Response.json({ hasPin: !!info.pinCode, hasPassword: !!info.password, unlocked: !!info.isElevated, expiresAt: info.pinExpiresAt ?? null, }); } /** * `POST /_locked/unlock` — sign in, elevate, keep the session. * * A wrong PIN has to close the session it just opened. Immich lists every sign-in as a device, so leaking one * per failed attempt would turn a mistyped PIN into a growing list of phantom devices in the owner's account. */ async function unlock(req: Request, cfg: UpstreamConfig, userId: number): Promise { const body = await readBody(req); const password = typeof body.password === 'string' ? body.password : ''; const pinCode = str(body.pinCode); if (!password) return bad('the Immich account password is required to unlock'); if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits'); let token: string; try { token = await login(cfg.base, await ownerEmail(cfg), password); } catch (err) { if (err instanceof UpstreamError) return bad(err.message, err.status === 401 ? 401 : 400); return bad(`could not reach the instance (${String(err)})`, 502); } const res = await callUpstream(cfg, { path: '/api/auth/session/unlock', method: 'POST', bearer: token, body: JSON.stringify({ pinCode }), contentType: 'application/json', }); if (!res.ok) { await logout(cfg.base, token); return bad(await message(res, 'the PIN was rejected'), res.status === 400 ? 400 : res.status); } // Replace rather than accumulate: unlocking twice should not leave the first session open. await drop(userId); elevations.set(userId, { accountId: cfg.id, base: cfg.base, token, lastUsedAt: Date.now() }); return Response.json({ unlocked: true }); } /** `POST /_locked/lock` — give up elevation immediately rather than waiting for it to lapse. */ async function lock(cfg: UpstreamConfig, userId: number): Promise { const session = current(userId, cfg); if (session) { await callUpstream(cfg, { path: '/api/auth/session/lock', method: 'POST', bearer: session.token }); await drop(userId); } return Response.json({ unlocked: false }); } /** * `/_locked/pin` — set up, change and remove the PIN, all on the API key. * * These genuinely do not need a session: `setupPinCode` and `validatePinCode` read `auth.user.id` and never * touch `auth.session` (auth.service.js), so the key is enough. That is what makes the private folder * something Officer can offer end to end instead of sending the owner to Immich's own settings screen first. */ async function pin(req: Request, cfg: UpstreamConfig, userId: number): Promise { const body = await readBody(req); const pinCode = str(body.pinCode); const newPinCode = str(body.newPinCode); const password = typeof body.password === 'string' ? body.password : ''; const call = (method: string, payload: Record) => callUpstream(cfg, { path: PIN_PATH, method, body: JSON.stringify(payload), contentType: 'application/json' }); if (req.method === 'POST') { if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits'); const res = await call('POST', { pinCode }); return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not set the PIN'), res.status); } // Change and reset both prove intent with the OLD pin or the account password, so the two are read together. const proof: Record | null = password ? { password } : pinCode ? { pinCode } : null; if (!proof) return bad('the current PIN, or the account password, is required'); if (req.method === 'PUT') { if (!PIN_RE.test(newPinCode)) return bad('the new PIN is six digits'); const res = await call('PUT', { ...proof, newPinCode }); return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not change the PIN'), res.status); } const res = await call('DELETE', proof); if (!res.ok) return bad(await message(res, 'could not remove the PIN'), res.status); // Immich drops elevation from every session when the PIN is reset; our copy has to go with it. await drop(userId); return Response.json({ hasPin: false, unlocked: false }); } /** * The elevated forward, deliberately a fraction of the main allow-list. * * Three resources, because three are enough to see the folder and move things in and out of it: read the * assets and their bytes, list them, and change their visibility. DELETE is absent on purpose — emptying the * trash or destroying an asset is reachable on the ordinary surface once it is out of the folder, and an * irreversible route is not worth putting behind a credential the owner cannot see. */ const ELEVATED: Record = { assets: ['GET', 'PUT'], search: ['POST'], timeline: ['GET'], }; /** `/_locked/api/` — the same shape as `/_officer/`, but on the session and far narrower. */ async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise { // The allow-list is checked BEFORE the session, and the order is deliberate. A resource this surface will // never carry has to say so whether or not the folder happens to be open — answering 423 would invite a // client to prompt for a password and a PIN to reach something that stays a 404 afterwards. const [resource] = rest.split('/'); const allowed = resource ? ELEVATED[resource] : undefined; if (!allowed) return Response.json({ error: 'not found' }, { status: 404 }); if (!allowed.includes(req.method)) return bad(`${req.method} not allowed on ${resource}`, 405); const session = current(userId, cfg); if (!session) return locked(); // Officer's own JWT arrives in `?token=` because an cannot send a header. Forwarding it would write the // owner's session credential into Immich's access log for every locked thumbnail. const query = new URLSearchParams(url.search); query.delete('token'); const search = query.toString(); const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; const res = await callUpstream(cfg, { path: `/api/${rest}`, method: req.method, query: search ? `?${search}` : '', // Streamed, not buffered — same reason as the unlocked forwarder in routes.ts. body: hasBody ? req.body : null, contentType: req.headers.get('content-type'), range: req.headers.get('range'), ifNoneMatch: req.headers.get('if-none-match'), bearer: session.token, }); // The window closed underneath us. Collapse to locked so the UI prompts again instead of showing an error. if (res.status === 401) { await drop(userId); return locked('the private folder locked itself again'); } session.lastUsedAt = Date.now(); return relayResponse(res); } /** `subpath` is the part after `/_locked`, e.g. '/status' or '/api/assets//thumbnail'. */ export async function handleLockedRoute( req: Request, cfg: UpstreamConfig, userId: number, subpath: string, ): Promise { const url = new URL(req.url); const [, head, ...tail] = subpath.split('/'); if (head === 'api') return forward(req, url, cfg, userId, tail.join('/')); if (head === 'status') { return req.method === 'GET' ? status(cfg, userId) : bad('method not allowed', 405); } if (head === 'unlock') { return req.method === 'POST' ? unlock(req, cfg, userId) : bad('method not allowed', 405); } if (head === 'lock') { return req.method === 'POST' ? lock(cfg, userId) : bad('method not allowed', 405); } if (head === 'pin') { const allowed = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE'; return allowed ? pin(req, cfg, userId) : bad('method not allowed', 405); } return Response.json({ error: 'not found' }, { status: 404 }); } /** * Close sessions nobody came back for. * * Without this an owner who unlocks and then closes the tab leaves a signed-in device in their Immich account * until the sidecar restarts. Elevation is long gone by then, so the session is useless to us and untidy there. */ setInterval(() => { const now = Date.now(); for (const [userId, session] of elevations) { if (now - session.lastUsedAt > IDLE_MS) void drop(userId); } }, 60_000).unref();