import type { UpstreamTarget } from './upstream'; import { createPhotosAccount, deletePhotosAccount, getPhotosCredentials, listPhotosAccounts, recordPhotosProbe, setActivePhotosAccount, updatePhotosAccount, } from 'officerdb'; import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, 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 new one" has two forms: a key the owner pasted from Immich's own settings screen, or an email and // password the sidecar trades for one (see mintApiKey). The password is used for that exchange and never // stored — only the key it yields is. // // 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 { 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 { 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 { 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; email?: unknown; password?: unknown }; const readBody = async (req: Request): Promise => ((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 readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : ''); const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : ''); const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); /** * The key to store, however the owner chose to supply it. * * A pasted key wins; otherwise an email and password are traded for a fresh one and the password is discarded * with this function's stack frame. Returns '' when neither was given, which for an edit means "keep what is * stored" and for an add is an error. */ async function resolveKey(body: AccountBody, base: string): Promise { const apiKey = readKey(body); if (apiKey) return apiKey; const email = readEmail(body); const password = readPassword(body); if (!email || !password) return ''; return mintApiKey(base, email, password); } /** A refused sign-in already carries Immich's own words; anything else never reached Immich at all. */ const signInError = (err: unknown): string => err instanceof UpstreamError ? err.message : `could not reach the instance (${String(err)})`; 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 { const body = await readBody(req); const url = readUrl(body); let label = readLabel(body); if (!url) return bad('url is required'); if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); let apiKey: string; try { apiKey = await resolveKey(body, url); } catch (err) { return bad(signInError(err)); } if (!apiKey) return bad('an API key, or an email and password to mint one, is required'); 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 { const body = await readBody(req); const label = readLabel(body); const url = readUrl(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://'); let apiKey: string; try { apiKey = await resolveKey(body, url || current.url); } catch (err) { return bad(signInError(err)); } // 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 '/' / '//activate'. */ export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { 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); }