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);
}