Files
photos/sidecar/upstream.ts
T
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:39 +00:00

228 lines
11 KiB
TypeScript

// 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<number, { cfg: UpstreamConfig | null; at: number }>();
/**
* 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<UpstreamConfig | null> {
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<string> {
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<string> {
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<void> {
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<string> {
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<Response> {
const headers: Record<string, string> = { 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;
}