photos, extracted from the platform into its own repository
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>
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
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)));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// The Immich accounts behind /photos, for the officer-photos sidecar.
|
||||
//
|
||||
// This used to be IMMICH_URL + IMMICH_API_KEY in the platform-wide `.env`, which was wrong twice over: Bun
|
||||
// auto-loads `.env` into EVERY process started in the platform directory, so `officer` itself held an Immich
|
||||
// credential it has no code to use — and connecting a photo library was a shell task on the server rather
|
||||
// than something the owner could do from the app.
|
||||
//
|
||||
// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the
|
||||
// same shape offscale_servers uses. Two accounts on the same instance is the normal case (one key per
|
||||
// Immich user), which is why the uniqueness below is on the label and not on the URL.
|
||||
//
|
||||
// `api_key` is encrypted at rest via ../crypto.ts. An Immich key can read and delete the entire library, so a
|
||||
// DB dump must not hand it over. Encryption is confined to queries/photos.ts; nothing outside that file sees
|
||||
// ciphertext, and no route ever returns the key at all.
|
||||
export const photosConfig = pgTable(
|
||||
'photos_config',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */
|
||||
label: text('label').notNull(),
|
||||
// Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator.
|
||||
url: text('url').notNull(),
|
||||
apiKey: text('api_key').notNull(), // encrypted
|
||||
/** Immich version seen at the last successful probe — shown in the UI, never used for behaviour. */
|
||||
version: text('version'),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Labels are how the owner tells two accounts apart — duplicates would make the switcher useless. Not
|
||||
// unique on url: several keys against one instance is the whole point.
|
||||
uniqueIndex('uq_photos_config_user_label').on(t.userId, t.label),
|
||||
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
|
||||
// index over the active rows only. setActivePhotosAccount still clears the others in a transaction, but a
|
||||
// bug there fails loudly here instead of silently leaving two accounts active and the UI picking one.
|
||||
uniqueIndex('uq_photos_config_one_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user