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>
178 lines
6.6 KiB
TypeScript
178 lines
6.6 KiB
TypeScript
import { eq, and, desc } from 'drizzle-orm';
|
|
import { db } from 'officerdb/db';
|
|
import { 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) };
|
|
}
|
|
|
|
/** 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)));
|
|
}
|