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>
228 lines
11 KiB
TypeScript
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;
|
|
}
|