Files
platform/src/servers/sidecar/photos/locked.ts
T
pastilhasandClaude Opus 5 63f7a14b6c stream request bodies through the photos sidecar
both forwarders buffered the whole request body into an ArrayBuffer before
re-sending it to Immich. with maxRequestBodySize at 4GB that put a phone's
video upload in the sidecar's heap for a hop that never reads the bytes.

callUpstream now sets duplex: 'half' so a stream is a legal body, matching
what createSidecarProxy already does on the platform side. the four JSON
callers are unaffected.

the platform proxy forwards no content-length, so the body already reached
us chunked; this extends that one hop to Immich. verified against the live
instance (3.1.0): bulk-upload-check round-trips a streamed body and returns
the right verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:45:01 +00:00

333 lines
15 KiB
TypeScript

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<number, Elevation>();
/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */
async function drop(userId: number): Promise<void> {
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<Body> => ((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<string> {
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<string> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<string, string>) =>
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<string, string> | 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<string, readonly string[]> = {
assets: ['GET', 'PUT'],
search: ['POST'],
timeline: ['GET'],
};
/** `/_locked/api/<path>` — the same shape as `/_officer/<path>`, but on the session and far narrower. */
async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise<Response> {
// 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 <img> 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/<id>/thumbnail'. */
export async function handleLockedRoute(
req: Request,
cfg: UpstreamConfig,
userId: number,
subpath: string,
): Promise<Response> {
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();