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>
237 lines
10 KiB
TypeScript
237 lines
10 KiB
TypeScript
import type { UpstreamTarget } from './upstream';
|
|
import {
|
|
createPhotosAccount,
|
|
deletePhotosAccount,
|
|
getPhotosCredentials,
|
|
listPhotosAccounts,
|
|
recordPhotosProbe,
|
|
setActivePhotosAccount,
|
|
updatePhotosAccount,
|
|
} from '../db/queries';
|
|
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 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);
|
|
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 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 === '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);
|
|
}
|