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>
50 lines
2.8 KiB
TypeScript
50 lines
2.8 KiB
TypeScript
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}`),
|
|
],
|
|
);
|