photos, extracted into plugins/photos — the platform half

Fifth extraction, and the clean case: no reverse dependency, configuration
already server-side, frontend already panels. Everything photos now lives at
gitea.officer.dev/plugins/photos.

Deleted, not commented: the sidecar, the api router, the db directory, the
Photos app (25 files), the screen, the App.tsx route pair, the Dashboard
barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev
barrel re-exports, the permission registry entry, both database barrels, the
hono.ts import comment, and the app-store catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── Test fixtures, repointed rather than deleted ──

Photos was the canonical app-store fixture across four test files, chosen for
being existing|provisioned with members: 'accounts'. Jellyfin has the identical
shape, so the assertions moved there — they are about the installer, not about
photos, and deleting them would have quietly dropped coverage of the provisioned
install path along with the plugin.

One loss worth naming: Immich's `provisioned` install mode went with the
catalogue entry. Docker provisioning for plugins does not exist yet, so photos
is point-at-your-own-instance until it does.

bunx tsgo clean. 1080 pass / 0 fail, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:10:52 +00:00
co-authored by Claude Opus 5
parent 7819eff6a1
commit a327dfc82c
53 changed files with 47 additions and 5274 deletions
-1
View File
@@ -49,7 +49,6 @@ export * from './dav';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './notify';
export * from './photos';
export * from './soulseek';
export * from './wallet';
@@ -1,12 +0,0 @@
export {
listPhotosAccounts,
getActivePhotosCredentials,
getPhotosCredentials,
createPhotosAccount,
updatePhotosAccount,
setActivePhotosAccount,
deletePhotosAccount,
recordPhotosProbe,
} from './queries';
export type { PhotosAccount, PhotosCredentials } from './queries';
@@ -1,177 +0,0 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { photosConfig } from './schema';
import { encryptSecret, decryptSecret } from '../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)));
}
@@ -1,49 +0,0 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../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}`),
],
);
-1
View File
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './photos/schema'; // photos_config officer-photos
// export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs