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>
333 lines
15 KiB
TypeScript
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();
|