Photos had no user provisioning at all: one registry row, the owner's, and every other account resolving to nothing. Same shape as the Jellyfin gap, but this one was never built rather than built and dropped. photos_accounts records who on Officer is who on Immich, and carries that person's own API key. getConfig falls back to it when the caller has no registry of their own, and there is no path from there to the owner's key — an Immich key with permissions:['all'] can read and delete the whole library, so a member resolving to it would be administering everyone's photographs rather than reading their own. Matching is on email, which Immich enforces uniqueness on and Officer already holds. That is exact where the Jellyfin side has to go through an uppercased username index and cope with two Officer names colliding into one. Two deliberate differences from the Jellyfin flow: The admin's own account is reported as itself, not as "no Officer account". That bug shipped there and was fixed in 591c0da; there was no reason to write it twice, so reconcile takes the key's identity from /api/users/me and the test pins it. And the temporary password is HANDED OVER rather than discarded. Jellyfin discards because it has no must-change-password flag, so anything handed over stays valid forever. Immich has one, and Immich's own app is where phone backup runs — discarding would leave a photo account that can never back a phone up again. So: set a password, spend it on a key, raise shouldChangePassword, return it once. POST /_config/:id/handover re-issues on demand, because "setting up my phone" happens long after provisioning and more than once. Verified against the running 3.1.0 image that an API key survives a password change, so re-issuing never costs Officer its access. Applied on alpha: conceiba and fedra linked, guipinto and diogo created, all four keyed and each resolving to their own Immich identity. jrg.conceicao left alone. Members still need the photos grant on the Member role before the permission gate lets them through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
import type { UpstreamTarget } from './upstream';
|
|
import {
|
|
createPhotosAccount,
|
|
deletePhotosAccount,
|
|
getPhotosCredentials,
|
|
listPhotosAccountLinks,
|
|
listPhotosAccounts,
|
|
recordPhotosProbe,
|
|
setActivePhotosAccount,
|
|
updatePhotosAccount,
|
|
} from '../db/queries';
|
|
import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream';
|
|
import { backfillAccountKeys, runProvisioning } from './provision-run';
|
|
import { reissuePassword } from './provision';
|
|
|
|
// `/_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 the offscale plugin'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<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; email?: unknown; password?: 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 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<string> {
|
|
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<Response> {
|
|
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);
|
|
|
|
// ── Provision, if this connection can ──
|
|
//
|
|
// The trigger is registering an ADMIN connection, not installing the plugin: at install time there is
|
|
// no credential, so there is nothing to provision with. Awaited, so the response says what happened —
|
|
// and, importantly, carries the temporary passwords, which exist nowhere else.
|
|
const provisioning = await runProvisioning({ cfg: { base: url, key: apiKey }, configId: account.id });
|
|
return Response.json({ account, user: result.user, provisioning });
|
|
} catch (err) {
|
|
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A fresh one-time password for one person, so they can set up Immich's own app.
|
|
*
|
|
* Returned here and nowhere else. Not logged, not stored, and not readable back — asking again issues a
|
|
* new one and invalidates the last, which is the correct behaviour for a credential whose whole life is
|
|
* the walk from this reply to the person it belongs to.
|
|
*/
|
|
async function handover(req: Request, userId: number, id: number): Promise<Response> {
|
|
const creds = await getPhotosCredentials(userId, id);
|
|
if (!creds) return bad('no such account', 404);
|
|
|
|
const body = ((await req.json().catch(() => null)) as { userId?: unknown } | null) ?? {};
|
|
const target = Number(body.userId);
|
|
if (!Number.isInteger(target) || target <= 0) return bad('userId is required');
|
|
|
|
const links = await listPhotosAccountLinks(id);
|
|
const account = links.find((l) => l.userId === target);
|
|
if (!account) return bad('that account is not provisioned on this instance', 404);
|
|
|
|
const temporaryPassword = await reissuePassword(
|
|
{ base: normalizeBase(creds.url), key: creds.apiKey },
|
|
{ immichUserId: account.immichUserId, email: account.immichEmail },
|
|
);
|
|
return Response.json({ email: account.immichEmail, temporaryPassword });
|
|
}
|
|
|
|
/**
|
|
* Re-run provisioning against an instance already registered, and mint whatever keys are missing.
|
|
*
|
|
* The repair action, and the one that carries hand-over passwords back. `addAccount` does this once, at
|
|
* connect; this is the same work without needing the owner to re-enter a credential.
|
|
*
|
|
* Owner-only WITHOUT a permission rule, and worth saying out loud: every `/_config` route resolves its
|
|
* account through `getPhotosCredentials(userId, id)`, scoped to the caller's own rows. A member holding
|
|
* `photos` at write reaches this handler and gets a 404, because they have no registry of their own — the
|
|
* key they read the library with lives on their link row, not here.
|
|
*/
|
|
async function reprovision(userId: number, id: number): Promise<Response> {
|
|
const creds = await getPhotosCredentials(userId, id);
|
|
if (!creds) return bad('no such account', 404);
|
|
|
|
const cfg = { base: normalizeBase(creds.url), key: creds.apiKey };
|
|
const provisioning = await runProvisioning({ cfg, configId: id });
|
|
const keys = await backfillAccountKeys({ cfg, configId: id });
|
|
|
|
return Response.json({ provisioning, keys });
|
|
}
|
|
|
|
/** 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 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 '/<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 === 'provision') {
|
|
if (req.method !== 'POST') return bad('method not allowed', 405);
|
|
return reprovision(userId, id);
|
|
}
|
|
|
|
if (action === 'handover') {
|
|
if (req.method !== 'POST') return bad('method not allowed', 405);
|
|
return handover(req, userId, id);
|
|
}
|
|
|
|
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);
|
|
}
|