Files
photos/db/queries.ts
T
pastilhasandClaude Fable 5 a83da42a1f give each member their own immich account and key
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>
2026-08-21 15:46:13 +00:00

293 lines
11 KiB
TypeScript

import { eq, and, desc, isNotNull } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { photosAccounts, photosConfig } from './schema';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
// Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from
// at-rest ciphertext happens here. See ../crypto.ts and ../schema/photos.ts.
//
// Two return types, and the split is the safety property:
// PhotosAccount — safe to serialize to the browser. Has NO api key field at all, not even a masked one.
// PhotosCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned by a route.
// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
// response the moment someone forgot to strip it.
export type PhotosAccount = {
id: number;
label: string;
url: string;
version: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type PhotosCredentials = { id: number; label: string; url: string; apiKey: string };
const accountCols = {
id: photosConfig.id,
label: photosConfig.label,
url: photosConfig.url,
version: photosConfig.version,
isActive: photosConfig.isActive,
lastSeenAt: photosConfig.lastSeenAt,
createdAt: photosConfig.createdAt,
};
/** Every account the owner has added, active first then newest. Never includes the API key. */
export async function listPhotosAccounts(userId: number): Promise<PhotosAccount[]> {
return db
.select(accountCols)
.from(photosConfig)
.where(eq(photosConfig.userId, userId))
.orderBy(desc(photosConfig.isActive), desc(photosConfig.createdAt));
}
/** The selected account with its key decrypted, or null when none is added. */
export async function getActivePhotosCredentials(userId: number): Promise<PhotosCredentials | null> {
const [row] = await db
.select()
.from(photosConfig)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
}
/**
* The credentials a NON-OWNER reads the library with: the owner's selected instance, and that person's own
* API key from their link row.
*
* `id` and `label` are the REGISTRY row's, so a probe is recorded against the instance it actually reached
* and everything downstream treats this as "the connection in use" without caring whose key is inside it.
* The only field that differs from the owner's is the one that must — the key.
*
* Null rather than a fallback in two cases, and both are deliberate:
*
* - **The instance must be the ACTIVE one.** Accounts are provisioned against one Immich, so a member
* linked to an instance the owner has since switched away from is genuinely not provisioned on the one
* in use.
* - **The key must exist.** Minting needs a password, which is not always ours to set.
*
* There is deliberately no path from here to the OWNER's key. An Immich key created with `permissions:
* ['all']` can read and delete the entire library, so a member resolving to it would not be reading their
* own photographs — they would be administering everyone's.
*/
export async function getLinkedPhotosCredentials(userId: number): Promise<PhotosCredentials | null> {
const [row] = await db
.select({
id: photosConfig.id,
label: photosConfig.label,
url: photosConfig.url,
apiKey: photosAccounts.apiKey,
})
.from(photosAccounts)
.innerJoin(photosConfig, eq(photosAccounts.configId, photosConfig.id))
.where(
and(eq(photosAccounts.userId, userId), eq(photosConfig.isActive, true), isNotNull(photosAccounts.apiKey)),
);
// The isNotNull above already excludes it; this narrows the type and keeps the guarantee local.
if (!row?.apiKey) return null;
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
}
/** One account's credentials by id — for probing a specific account rather than the active one. */
export async function getPhotosCredentials(userId: number, id: number): Promise<PhotosCredentials | null> {
const [row] = await db
.select()
.from(photosConfig)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
}
type CreatePhotosAccountParams = {
userId: number;
label: string;
url: string;
apiKey: string;
version: string | null;
/** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */
activate: boolean;
};
/** Add an account. The key is encrypted before write; the returned row carries no key. */
export async function createPhotosAccount(params: CreatePhotosAccountParams): Promise<PhotosAccount> {
const { userId, label, url, apiKey, version, activate } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
.update(photosConfig)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
}
const [row] = await tx
.insert(photosConfig)
.values({
userId,
label,
url,
apiKey: encryptSecret('photos', apiKey),
version,
isActive: activate,
lastSeenAt: version ? new Date() : null,
})
.returning(accountCols);
return row!;
});
}
type UpdatePhotosAccountParams = { label?: string; url?: string; apiKey?: string; version?: string | null };
/** Edit an account. Omitted fields are left alone; a supplied key is re-encrypted. */
export async function updatePhotosAccount(
userId: number,
id: number,
params: UpdatePhotosAccountParams,
): Promise<PhotosAccount | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.label !== undefined) set.label = params.label;
if (params.url !== undefined) set.url = params.url;
if (params.apiKey !== undefined) set.apiKey = encryptSecret('photos', params.apiKey);
if (params.version !== undefined) set.version = params.version;
const [row] = await db
.update(photosConfig)
.set(set)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
.returning(accountCols);
return row ?? null;
}
/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */
export async function setActivePhotosAccount(userId: number, id: number): Promise<PhotosAccount | null> {
return db.transaction(async (tx) => {
await tx
.update(photosConfig)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
const [row] = await tx
.update(photosConfig)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
.returning(accountCols);
return row ?? null;
});
}
/**
* Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the
* account in use would leave the owner with accounts added but none selected, which reads as "not connected"
* and is a confusing place to land.
*/
export async function deletePhotosAccount(userId: number, id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx
.delete(photosConfig)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
.returning({ id: photosConfig.id, wasActive: photosConfig.isActive });
if (!deleted) return false;
if (deleted.wasActive) {
const [next] = await tx
.select({ id: photosConfig.id })
.from(photosConfig)
.where(eq(photosConfig.userId, userId))
.orderBy(desc(photosConfig.createdAt))
.limit(1);
if (next) {
await tx
.update(photosConfig)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(photosConfig.id, next.id));
}
}
return true;
});
}
/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */
export async function recordPhotosProbe(userId: number, id: number, version: string | null): Promise<void> {
await db
.update(photosConfig)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
}
// ── The Officer ↔ Immich account mapping ──
/**
* Links already recorded for one instance, for the reconcile to compare against.
*
* `hasKey` rather than the key itself: callers need to know whether this person can reach their library,
* and none of them needs the credential to answer that. The ciphertext does not leave this file.
*/
export async function listPhotosAccountLinks(configId: number): Promise<
{ userId: number; immichUserId: string; immichEmail: string; adopted: boolean; hasKey: boolean }[]
> {
const rows = await db
.select({
userId: photosAccounts.userId,
immichUserId: photosAccounts.immichUserId,
immichEmail: photosAccounts.immichEmail,
adopted: photosAccounts.adopted,
apiKey: photosAccounts.apiKey,
})
.from(photosAccounts)
.where(eq(photosAccounts.configId, configId));
return rows.map(({ apiKey, ...rest }) => ({ ...rest, hasKey: !!apiKey }));
}
/**
* Record a pairing.
*
* `onConflictDoNothing` on BOTH unique indexes, so a re-run is silent rather than an error — a second
* reconcile finding the same thing already true is the normal case, not a fault.
*/
export async function linkPhotosAccount(params: {
userId: number;
configId: number;
immichUserId: string;
immichEmail: string;
adopted: boolean;
}): Promise<void> {
await db
.insert(photosAccounts)
.values({
userId: params.userId,
configId: params.configId,
immichUserId: params.immichUserId,
immichEmail: params.immichEmail,
// Mirrors what Immich stores, so matching is a lookup rather than a convention every call site has
// to remember.
normalizedEmail: params.immichEmail.trim().toLowerCase(),
adopted: params.adopted,
})
.onConflictDoNothing();
}
/**
* Store the key minted for one person, encrypted.
*
* Separate from `linkPhotosAccount` rather than a parameter on it, for the reason the Jellyfin side learned
* the hard way: linking is `onConflictDoNothing`, but a key is exactly the thing you re-run provisioning to
* REPLACE. An insert that does nothing on conflict cannot express that.
*/
export async function setPhotosAccountKey(params: {
userId: number;
configId: number;
apiKey: string;
}): Promise<void> {
await db
.update(photosAccounts)
.set({ apiKey: encryptSecret('photos', params.apiKey) })
.where(and(eq(photosAccounts.userId, params.userId), eq(photosAccounts.configId, params.configId)));
}
/** Drop a pairing. Removes Officer's RECORD of the account, never the account itself. */
export async function unlinkPhotosAccount(userId: number, configId: number): Promise<void> {
await db
.delete(photosAccounts)
.where(and(eq(photosAccounts.userId, userId), eq(photosAccounts.configId, configId)));
}