photos: immich accounts are configured from the ui, not the environment

IMMICH_URL/IMMICH_API_KEY lived in the platform-wide .env, which was wrong
twice over: bun auto-loads .env into every process started in this directory,
so `officer` itself held an immich credential it has no code to use — and
connecting a library was a shell task on the server rather than something the
owner could do from the app.

it is a registry, not a single connection: any number of labelled accounts with
one selected, the same shape headscale_servers uses. two keys against the same
instance (one per immich user) is the ordinary case, so the label is what has to
be unique, not the url. one active account per owner is enforced by a partial
unique index rather than by convention.

keys are encrypted at rest and write-only across the sidecar boundary — no route
returns one, masked or otherwise. every save is validated against the live
instance first, so a wrong or under-scoped key is a 400 with the reason instead
of a stored row that makes every later screen fail mysteriously.

the drizzle snapshot under migrations/ is regenerated; nothing applies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 16:05:58 +00:00
co-authored by Claude Opus 5
parent c77e7ee598
commit 632d5a1c1f
17 changed files with 3033 additions and 1059 deletions
+195
View File
@@ -0,0 +1,195 @@
import type { UpstreamTarget } from './upstream';
import {
createPhotosAccount,
deletePhotosAccount,
getPhotosCredentials,
listPhotosAccounts,
recordPhotosProbe,
setActivePhotosAccount,
updatePhotosAccount,
} from 'officerdb';
import { callUpstream, invalidateConfig, normalizeBase } from './upstream';
// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server.
//
// The API key is WRITE-ONLY across this boundary. The list reports each account's label, URL and whether it
// is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to
// send a new one, which is the same shape headscale's server registry uses.
//
// A save is validated before it is stored: an Immich key that is missing, wrong or under-scoped is a 400
// with the reason, not a saved row that makes every later screen fail mysteriously. That matters more here
// than usual — Immich keys are SCOPED, and a key created without the right permissions returns 403 on
// individual routes while looking perfectly valid on the ones it does cover.
//
// Adding, editing, switching and removing all invalidate the upstream cache. Forgetting one would leave the
// sidecar serving the previous account's photos for up to a minute, which reads as a caching bug in the grid
// rather than as what it is.
export type ProbeResult =
| { ok: true; version: string | null; user: string | null }
| { ok: false; version: string | null; error: string };
/**
* Ask an instance whether it is really there and whether the key works.
*
* Two calls, because they answer different questions: `/server/version` is unauthenticated, so a failure
* there means the URL is wrong or Immich is down, while `/users/me` failing after it succeeded means the
* key is the problem. Collapsing them would report "instance unreachable" for a mistyped key.
*/
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
let version: string | null = null;
try {
const versionRes = await callUpstream(cfg, { path: '/api/server/version', withKey: false });
if (!versionRes.ok) return { ok: false, version: null, error: `instance returned ${versionRes.status}` };
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
if ([v.major, v.minor, v.patch].every((n) => typeof n === 'number')) {
version = `${v.major}.${v.minor}.${v.patch}`;
}
} catch (err) {
return { ok: false, version: null, error: `could not reach the instance (${String(err)})` };
}
try {
const meRes = await callUpstream(cfg, { path: '/api/users/me' });
if (!meRes.ok) {
const reason = meRes.status === 403 ? 'API key is under-scoped for this instance' : 'API key was rejected';
return { ok: false, version, error: `${reason} (${meRes.status})` };
}
const me = (await meRes.json()) as { email?: string; name?: string };
return { ok: true, version, user: me.email ?? me.name ?? null };
} catch (err) {
return { ok: false, version, error: String(err) };
}
}
/** What the browser is allowed to know about the registry. Never includes a key. */
async function accountList(userId: number): Promise<Response> {
const accounts = await listPhotosAccounts(userId);
const active = accounts.find((account) => account.isActive) ?? null;
return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts });
}
/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
await recordPhotosProbe(userId, id, version).catch(() => {
/* a stale lastSeenAt is not worth failing a request over */
});
}
const bad = (error: string, status = 400) => Response.json({ error }, { status });
type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown };
const readBody = async (req: Request): Promise<AccountBody> =>
((await req.json().catch(() => null)) as AccountBody | null) ?? {};
const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : '');
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label');
/** Add an account: validated against the live instance, then stored encrypted. */
async function addAccount(req: Request, userId: number): Promise<Response> {
const body = await readBody(req);
const url = readUrl(body);
const apiKey = readKey(body);
let label = readLabel(body);
if (!url || !apiKey) return bad('url and apiKey are required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
const result = await probe({ base: url, key: apiKey });
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
// An unlabelled account takes the name Immich itself knows it by, which is nearly always what the owner
// would have typed. Falling back to the host keeps the switcher readable even for an anonymous key.
if (!label) label = result.user ?? new URL(url).host;
// The first account wins the selection: a registry with rows but nothing selected reads as "not connected".
const existing = await listPhotosAccounts(userId);
const activate = existing.length === 0;
try {
const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate });
invalidateConfig(userId);
return Response.json({ account, user: result.user });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
throw err;
}
}
/** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */
async function editAccount(req: Request, userId: number, id: number): Promise<Response> {
const body = await readBody(req);
const label = readLabel(body);
const url = readUrl(body);
const apiKey = readKey(body);
const current = await getPhotosCredentials(userId, id);
if (!current) return bad('no such account', 404);
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
// Re-validate whenever what we would talk to changes. A rename on its own never touches the instance.
let version: string | null | undefined;
if ((url && url !== current.url) || apiKey) {
const result = await probe({ base: url || current.url, key: apiKey || current.apiKey });
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
version = result.version;
}
try {
const account = await updatePhotosAccount(userId, id, {
label: label || undefined,
url: url || undefined,
apiKey: apiKey || undefined,
version,
});
if (!account) return bad('no such account', 404);
invalidateConfig(userId);
return Response.json({ account });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
throw err;
}
}
/** `subpath` is '' for /_config, or '/<id>' / '/<id>/activate'. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
const [, rawId, action] = subpath.split('/');
if (!rawId) {
if (req.method === 'GET') return accountList(userId);
if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId);
return bad('method not allowed', 405);
}
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);
if (action === 'activate') {
if (req.method !== 'POST') return bad('method not allowed', 405);
const account = await setActivePhotosAccount(userId, id);
if (!account) return bad('no such account', 404);
invalidateConfig(userId);
return accountList(userId);
}
if (action) return bad('not found', 404);
if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id);
if (req.method === 'DELETE') {
const removed = await deletePhotosAccount(userId, id);
if (!removed) return bad('no such account', 404);
invalidateConfig(userId);
return accountList(userId);
}
return bad('method not allowed', 405);
}
+57 -31
View File
@@ -1,12 +1,16 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute, noteProbe, probe } from './config';
import { handleOfficerRoute } from './routes';
import { callUpstream, getBase, getConfig } from './upstream';
import { getConfig } from './upstream';
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
// credentials.
//
// The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no
// longer read from the environment. See upstream.ts for why that move mattered.
//
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
//
@@ -15,9 +19,18 @@ import { callUpstream, getBase, getConfig } from './upstream';
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
//
// GET /_health ours. Confirms the key is live and reports the Immich version and who the key is.
// * /_officer/<path> forwarded to <IMMICH_URL>/api/<path>, first-segment allow-list (routes.ts)
// anything else 404
// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is.
// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] }
// POST /_config { label?, url, apiKey } — validated against the instance, then stored encrypted
// PATCH /_config/:id { label?, url?, apiKey? } — a blank key keeps the stored one
// POST /_config/:id/activate switch to that account
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
// anything else 404
//
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
// loopback only, so its presence is the trust signal — a request without it did not come through the
// platform, and the connection is per-owner data.
//
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
@@ -44,39 +57,52 @@ const server = Bun.serve({
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const cfg = getConfig();
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, error: 'IMMICH_URL/IMMICH_API_KEY not configured' }, { status: 503 });
const started = Date.now();
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
try {
// Version is public, so it separates "instance down" from "key rejected" in one shot.
const [versionRes, meRes] = await Promise.all([
callUpstream(cfg, { path: '/api/server/version', withKey: false }),
callUpstream(cfg, { path: '/api/users/me' }),
]);
if (!versionRes.ok) {
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
}
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
const version = [v.major, v.minor, v.patch].every((n) => typeof n === 'number')
? `${v.major}.${v.minor}.${v.patch}`
: null;
if (!meRes.ok) {
return Response.json(
{ ok: false, version, error: `IMMICH_API_KEY rejected (${meRes.status})`, ms: Date.now() - started },
{ status: 502 },
);
}
const me = (await meRes.json()) as { email?: string; name?: string };
return Response.json({ ok: true, version, user: me.email ?? me.name ?? null, ms: Date.now() - started });
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
} catch (err) {
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
const cfg = await getConfig(userId);
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
// configured-but-broken instance is the whole reason the flag is on the response.
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(cfg);
const ms = Date.now() - started;
if (!result.ok) {
return Response.json(
{ ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms },
{ status: 502 },
);
}
await noteProbe(userId, cfg.id, result.version);
return Response.json({
ok: true,
configured: true,
account: cfg.label,
version: result.version,
user: result.user,
ms,
});
}
if (url.pathname.startsWith('/_officer/')) {
if (!cfg) return Response.json({ error: 'photos not configured' }, { status: 503 });
if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
try {
const res = await handleOfficerRoute(cfg, req, url);
if (res) return res;
@@ -91,7 +117,7 @@ const server = Bun.serve({
},
});
console.log(`[photos] listening on 127.0.0.1:${port} -> ${getBase() ?? '(IMMICH_URL unset)'}`);
console.log(`[photos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in photos_config)`);
type ReplyFn = (msg: SidecarEvent) => void;
+45 -27
View File
@@ -1,8 +1,13 @@
// Immich upstream config for the officer-photos sidecar.
//
// All knowledge of the Immich instance — its URL and its API key — lives here, mirroring
// officer-invoiceshelf/officer-transmission/officer-slskd: the platform API is a thin auth+forward proxy
// and holds NO Immich credentials.
// 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/queries/photos.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:
//
@@ -14,33 +19,46 @@
// 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.
const { IMMICH_URL, IMMICH_API_KEY } = process.env;
import { getActivePhotosCredentials } from 'officerdb';
export type UpstreamConfig = { base: string; key: string };
let warnedUnset = false;
/** The instance URL alone, for logging — set without a key is a real state and should read as one. */
export function getBase(): string | null {
return IMMICH_URL?.trim().replace(/\/+$/, '') || null;
}
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
export type UpstreamTarget = { base: string; key: string };
/**
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
* 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 function getConfig(): UpstreamConfig | null {
const base = IMMICH_URL?.trim().replace(/\/+$/, '');
const key = IMMICH_API_KEY?.trim();
if (!base || !key) {
if (!warnedUnset) {
const missing = [!base && 'IMMICH_URL', !key && 'IMMICH_API_KEY'].filter(Boolean).join(' and ');
console.warn(`[photos] ${missing} unset — the sidecar will respond 503 until set`);
warnedUnset = true;
}
return null;
}
return { base, key };
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);
}
type CallOptions = {
@@ -59,7 +77,7 @@ type CallOptions = {
};
/** The single door to Immich. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (opts.withKey !== false) headers['x-api-key'] = cfg.key;