diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 2158cef6..9371a7c6 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -84,8 +84,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> } /> {/* The screen was `/plugins` until marketplace.officer.dev existed. Redirected rather than diff --git a/src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx deleted file mode 100644 index 9ca5731f..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Navigate, useParams } from 'react-router'; -import type { LayoutNode } from 'officerdev'; -import { WorkspaceView, DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; -import { defaultLayout } from './defaultLayout'; - -// /photos uses the Workspace/Panel system: the section nav (photos-nav) on the left, the section view -// (photos-view) on the right. Both talk to the officer-photos sidecar through the /api/photos auth proxy; the -// Immich URL and API key live in the sidecar's environment and the browser never sees either. -// -// The open section is :section in the URL; deeper selection (which asset, album, person, search) is in the -// query string. Nothing about "what is open" lives in a panel channel. - -export const PhotosScreen = () => { - const { section } = useParams(); - const workspace = useDashboardState('screens/photos', defaultLayout); - - // Bare /photos, or a section that doesn't exist, canonicalises rather than rendering a default behind a URL - // that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing. - if (!isPhotosSection(section)) { - return ; - } - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Photos/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Photos/defaultLayout.ts deleted file mode 100644 index 37936710..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Photos/defaultLayout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'photos-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'photos-nav', appType: 'photos-nav' }, size: 18 }, - { node: { type: 'panel', id: 'photos-view', appType: 'photos-view' }, size: 82 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Photos/index.tsx b/src/apps/officer-web/Screens/Dashboard/Photos/index.tsx deleted file mode 100644 index 32b5dff8..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Photos/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './PhotosScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 52597f3e..9679bc93 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -15,7 +15,6 @@ export * from './Calendar'; export * from './Contacts'; export * from './Soulseek'; export * from './CommandHQ'; -export * from './Photos'; export * from './Jellyfin'; export * from './Transmission'; export * from './Gitea'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index a26b83b9..fac7b6f6 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -23,7 +23,6 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/app-store'), title: 'App store' }, { match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' }, { match: (p) => p.startsWith('/plugins'), title: 'Marketplace' }, - { match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/jellyfin'), title: 'Video' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, { match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' }, diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 1a9884a8..8a2e7c5a 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/photos/index.ts b/src/databases/officer_db/src/photos/index.ts deleted file mode 100644 index b7b9dc53..00000000 --- a/src/databases/officer_db/src/photos/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - listPhotosAccounts, - getActivePhotosCredentials, - getPhotosCredentials, - createPhotosAccount, - updatePhotosAccount, - setActivePhotosAccount, - deletePhotosAccount, - recordPhotosProbe, -} from './queries'; - -export type { PhotosAccount, PhotosCredentials } from './queries'; diff --git a/src/databases/officer_db/src/photos/queries.ts b/src/databases/officer_db/src/photos/queries.ts deleted file mode 100644 index 43592328..00000000 --- a/src/databases/officer_db/src/photos/queries.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - const set: Record = { 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 { - 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 { - 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 { - await db - .update(photosConfig) - .set({ version, lastSeenAt: new Date() }) - .where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id))); -} diff --git a/src/databases/officer_db/src/photos/schema.ts b/src/databases/officer_db/src/photos/schema.ts deleted file mode 100644 index f19528ee..00000000 --- a/src/databases/officer_db/src/photos/schema.ts +++ /dev/null @@ -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}`), - ], -); diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 0b768bcc..b6318807 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -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 diff --git a/src/servers/api/photos/router.ts b/src/servers/api/photos/router.ts deleted file mode 100644 index b4d80e2a..00000000 --- a/src/servers/api/photos/router.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createSidecarProxy } from '../../sidecar/create-proxy'; - -// /api/photos/* — auth, then forward to officer-photos. No routes of its own and no Immich knowledge: -// this file must never grow app logic. -// -// The sidecar owns the Immich contract and holds its API key. - -const proxy = createSidecarProxy({ - name: 'photos', - prefix: '/api/photos', - // Originals and `download/archive` zips are large and Immich builds the archive as it streams it, so the - // socket can sit quiet longer than the default 60s idle drop allows. - timeoutSeconds: 600, - // Immich judges an asset from its first few KB and rejects immediately, while we are still writing — - // and the response is lost in the teardown, which is what made every large upload fail with an - // unexplained empty 400. Buffering here removes the overlap. The sidecar does the same on its own hop - // to Immich (sidecar/photos/routes.ts → readBodyCapped), and both are needed: fixing one alone still - // lost the answer roughly two runs in three at 32 MB. - bufferRequestBody: true, -}); - -export const photosRouter = proxy.router; - -/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ -export const getPhotosServerUrl = proxy.getHttpUrl; diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index b03f3280..7dc95686 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -94,7 +94,7 @@ describe('permissions it claims to back', () => { describe('byId', () => { it('finds a known entry and returns undefined for anything else', () => { - expect(byId('photos')?.process).toBe('officer-photos'); + expect(byId('jellyfin')?.process).toBe('officer-jellyfin'); expect(byId('not-a-sidecar')).toBeUndefined(); }); }); diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index c25e8a68..68daaeb5 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -163,27 +163,12 @@ export type CatalogueEntry = { export const CATALOGUE: CatalogueEntry[] = [ // ── Point at something you already run, or let us start one ──────────────────────────────────────── - { - id: 'photos', - ui: { name: 'Photos', icon: 'Images', color: '#10b981', rootRoute: '/photos', routes: ['/photos'] }, - process: 'officer-photos', - label: 'Photos', - summary: 'Your Immich library — browse, search, upload from the phone', - members: 'accounts', - modes: ['existing', 'provisioned'], - permission: 'photos', - composeTemplate: 'immich', - existingFields: [ - { key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' }, - { - key: 'secret', - label: 'API key', - type: 'secret', - required: true, - help: 'Immich → Account Settings → API Keys. Create it with all permissions: a scoped key returns 403 per route, which reads as a broken feature.', - }, - ], - }, + // Photos was here until 2026-08-18, when it became `plugins/photos/`. + // + // Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having + // bitten twice. `permissionAvailability()` derives from `sidecar_installs`, which a plugin never gets a + // row in, so a leftover entry would make the permission permanently `unavailable`, put `/photos` into + // `deniedRoutes`, and withhold the dock tile — on a server where the plugin is installed and working. { id: 'jellyfin', ui: { name: 'Video', icon: 'Clapperboard', color: '#a855f7', rootRoute: '/jellyfin', routes: ['/jellyfin'] }, diff --git a/src/servers/app-store/installer.test.ts b/src/servers/app-store/installer.test.ts index 1eb81f9c..c50cd974 100644 --- a/src/servers/app-store/installer.test.ts +++ b/src/servers/app-store/installer.test.ts @@ -6,7 +6,7 @@ import { byId } from './catalogue'; // the reason the effects are injected. What is being pinned here is the behaviour that only shows up // when something goes wrong: resume, blocking, and not doing work twice. -const photos = byId('photos')!; // existing|provisioned, members: 'accounts' +const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts' const transmission = byId('transmission')!; // existing|provisioned, members: 'none' const configOnly = byId('notify')!; // config only @@ -39,15 +39,15 @@ describe('planSteps', () => { it('never provisions when pointing at an instance the user already runs', () => { // The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather // than remembered at each call site. - expect(planSteps(photos, 'existing')).not.toContain('provision'); - expect(planSteps(photos, 'provisioned')).toContain('provision'); + expect(planSteps(jellyfin, 'existing')).not.toContain('provision'); + expect(planSteps(jellyfin, 'provisioned')).toContain('provision'); }); it('omits the members step for a service with no user concept', () => { // Otherwise a Transmission install reports a members step that did nothing, which reads as a // silent failure to anyone debugging why a member has no access. expect(planSteps(transmission, 'provisioned')).not.toContain('members'); - expect(planSteps(photos, 'provisioned')).toContain('members'); + expect(planSteps(jellyfin, 'provisioned')).toContain('members'); }); it('has nothing to connect for a config-only install', () => { @@ -57,7 +57,7 @@ describe('planSteps', () => { it('always starts with preflight', () => { // Checking the host before anything is written is the whole reason a half-install is avoidable. for (const mode of ['existing', 'provisioned', 'config'] as const) { - expect(planSteps(photos, mode)[0]).toBe('preflight'); + expect(planSteps(jellyfin, mode)[0]).toBe('preflight'); } }); }); @@ -65,7 +65,7 @@ describe('planSteps', () => { describe('a clean run', () => { it('executes the plan in order and reports installed', async () => { const { effects, calls } = spyEffects(); - const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); + const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('installed'); expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']); @@ -82,7 +82,7 @@ describe('a clean run', () => { }, }); - await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects }); + await runInstall({ entry: jellyfin, mode: 'provisioned', values: { given: 'yes' }, effects }); expect(seen.url).toBe('http://127.0.0.1:18091'); expect(seen.composeDir).toBe('/root/dockers/x'); @@ -92,7 +92,7 @@ describe('a clean run', () => { it('does not mutate the caller’s values', async () => { const values = { given: 'yes' }; const { effects } = spyEffects(); - await runInstall({ entry: photos, mode: 'provisioned', values, effects }); + await runInstall({ entry: jellyfin, mode: 'provisioned', values, effects }); expect(values).toEqual({ given: 'yes' }); }); }); @@ -103,7 +103,7 @@ describe('resuming', () => { const { effects, calls } = spyEffects(); const done: StepName[] = ['preflight', 'provision', 'connect']; - const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects }); + const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, completed: done, effects }); expect(out.status).toBe('installed'); expect(calls).toEqual(['schema', 'assets', 'process', 'members']); @@ -113,7 +113,7 @@ describe('resuming', () => { it('reports every completed step, including the ones it skipped', async () => { const { effects } = spyEffects(); const out = await runInstall({ - entry: photos, + entry: jellyfin, mode: 'provisioned', values: {}, completed: ['preflight'], @@ -131,7 +131,7 @@ describe('blocking on a human', () => { connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }), }); - const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); + const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('blocked'); if (out.status !== 'blocked') throw new Error('unreachable'); @@ -145,7 +145,7 @@ describe('blocking on a human', () => { it('re-runs the blocking step on resume, once the human has answered', async () => { const { effects, calls } = spyEffects(); const out = await runInstall({ - entry: photos, + entry: jellyfin, mode: 'provisioned', values: { secret: 'now-provided' }, completed: ['preflight', 'provision'], @@ -165,7 +165,7 @@ describe('failing', () => { }, }); - const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); + const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('failed'); if (out.status !== 'failed') throw new Error('unreachable'); @@ -184,7 +184,7 @@ describe('failing', () => { }, }); - const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); + const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('failed'); if (out.status !== 'failed') throw new Error('unreachable'); @@ -203,7 +203,7 @@ describe('failing', () => { throw new Error('pm2 not found'); }, }); - const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); + const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('failed'); if (out.status !== 'failed') throw new Error('unreachable'); expect(out.at).toBe('process'); diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts index c9172d20..9a252ff0 100644 --- a/src/servers/app-store/members.test.ts +++ b/src/servers/app-store/members.test.ts @@ -10,7 +10,7 @@ const inviteOnly = { members: 'invite' } as CatalogueEntry; describe('what adding a member would do', () => { it('predicts the outcome per service without contacting anything', () => { - expect(plannedOutcome(byId('photos')!)).toBe('provisioned'); + expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned'); expect(plannedOutcome(inviteOnly)).toBe('invited'); expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable'); }); @@ -18,8 +18,8 @@ describe('what adding a member would do', () => { describe('which installed services have work to do', () => { it('skips single-tenant daemons entirely', () => { - const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'memos']).map((e) => e.id); - expect(ids.sort()).toEqual(['memos', 'photos']); + const ids = servicesNeedingMemberProvision(['jellyfin', 'transmission', 'slskd', 'memos']).map((e) => e.id); + expect(ids.sort()).toEqual(['jellyfin', 'memos']); }); it('ignores services that are not installed', () => { diff --git a/src/servers/app-store/preflight.test.ts b/src/servers/app-store/preflight.test.ts index 900bee5b..dbe573bb 100644 --- a/src/servers/app-store/preflight.test.ts +++ b/src/servers/app-store/preflight.test.ts @@ -12,8 +12,8 @@ describe('preflight is per mode, not per entry', () => { it('allows pointing at an existing instance without Docker', async () => { // The whole point: a host with no Docker can still use Photos against an Immich elsewhere. // Refusing the entry outright is the over-strict check that makes people bypass the installer. - const photos = byId('photos')!; - expect(await preflight(photos, 'existing')).toEqual({ ok: true }); + const jellyfin = byId('jellyfin')!; + expect(await preflight(jellyfin, 'existing')).toEqual({ ok: true }); }); it('allows a config-only sidecar regardless', async () => { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 47f99b57..351333aa 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -28,7 +28,6 @@ import { agentHandoffRouter } from './api/agent-handoff/router'; // import { transmissionRouter } from './api/transmission/router'; // import { invoiceshelfRouter } from './api/invoiceshelf/router'; // import { jellyfinRouter } from './api/jellyfin/router'; -// import { photosRouter } from './api/photos/router'; // import { walletRouter } from './api/wallet/router'; import { terminalRouter } from './api/terminal/sidecar-server'; // import { caldavRouter } from './api/dav/sidecar-server'; diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 71928e18..cf3ff55c 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -161,15 +161,6 @@ const CORE_REGISTRY: Permission[] = [ // commenting on their own issues — security theatre with a real cost and no benefit. personal: ['/'], }, - { - key: 'photos', - label: 'Photos', - description: 'Browse the photo library', - kind: 'app', - // api: ['/photos'], // plugin — switched off 2026-08-13 - api: [], - routes: ['/photos'], - }, { key: 'jellyfin', label: 'Video', diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 0d4a9b9e..756fd01a 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -109,6 +109,26 @@ const BUNDLED: CatalogueEntry[] = [ // Email ships all four: the proxy router, `email_accounts`, the sync sidecar, and the mail panels. has: { api: true, schema: true, sidecar: true, web: true }, }, + { + appName: 'photos', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Photos', + summary: 'Your Immich library — browse, search, upload from the phone', + color: '#10b981', + icon: 'Images', + source: 'https://gitea.officer.dev/plugins/photos.git', + permissions: [ + { + key: 'photos', + label: 'Photos', + description: 'Browse the photo library', + }, + ], + // No osDependencies: the sidecar is a Bun HTTP proxy and reaches Immich with `fetch`. + has: { api: true, schema: true, sidecar: true, web: true }, + }, { appName: 'vault', publisher: 'officerdev', diff --git a/src/servers/sidecar/photos/config.ts b/src/servers/sidecar/photos/config.ts deleted file mode 100644 index 0cb3fa6d..00000000 --- a/src/servers/sidecar/photos/config.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { UpstreamTarget } from './upstream'; -import { - createPhotosAccount, - deletePhotosAccount, - getPhotosCredentials, - listPhotosAccounts, - recordPhotosProbe, - setActivePhotosAccount, - updatePhotosAccount, -} from 'officerdb'; -import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream'; - -// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server. -// -// The API key is WRITE-ONLY across this boundary. The list reports each account's label, URL and whether it -// is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to -// send a new one, which is the same shape the offscale plugin's server registry uses. -// -// "A new one" has two forms: a key the owner pasted from Immich's own settings screen, or an email and -// password the sidecar trades for one (see mintApiKey). The password is used for that exchange and never -// stored — only the key it yields is. -// -// A save is validated before it is stored: an Immich key that is missing, wrong or under-scoped is a 400 -// with the reason, not a saved row that makes every later screen fail mysteriously. That matters more here -// than usual — Immich keys are SCOPED, and a key created without the right permissions returns 403 on -// individual routes while looking perfectly valid on the ones it does cover. -// -// Adding, editing, switching and removing all invalidate the upstream cache. Forgetting one would leave the -// sidecar serving the previous account's photos for up to a minute, which reads as a caching bug in the grid -// rather than as what it is. - -export type ProbeResult = - | { ok: true; version: string | null; user: string | null } - | { ok: false; version: string | null; error: string }; - -/** - * Ask an instance whether it is really there and whether the key works. - * - * Two calls, because they answer different questions: `/server/version` is unauthenticated, so a failure - * there means the URL is wrong or Immich is down, while `/users/me` failing after it succeeded means the - * key is the problem. Collapsing them would report "instance unreachable" for a mistyped key. - */ -export async function probe(cfg: UpstreamTarget): Promise { - let version: string | null = null; - - try { - const versionRes = await callUpstream(cfg, { path: '/api/server/version', withKey: false }); - if (!versionRes.ok) return { ok: false, version: null, error: `instance returned ${versionRes.status}` }; - - const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number }; - if ([v.major, v.minor, v.patch].every((n) => typeof n === 'number')) { - version = `${v.major}.${v.minor}.${v.patch}`; - } - } catch (err) { - return { ok: false, version: null, error: `could not reach the instance (${String(err)})` }; - } - - try { - const meRes = await callUpstream(cfg, { path: '/api/users/me' }); - if (!meRes.ok) { - const reason = meRes.status === 403 ? 'API key is under-scoped for this instance' : 'API key was rejected'; - return { ok: false, version, error: `${reason} (${meRes.status})` }; - } - const me = (await meRes.json()) as { email?: string; name?: string }; - return { ok: true, version, user: me.email ?? me.name ?? null }; - } catch (err) { - return { ok: false, version, error: String(err) }; - } -} - -/** What the browser is allowed to know about the registry. Never includes a key. */ -async function accountList(userId: number): Promise { - const accounts = await listPhotosAccounts(userId); - const active = accounts.find((account) => account.isActive) ?? null; - return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts }); -} - -/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */ -export async function noteProbe(userId: number, id: number, version: string | null): Promise { - await recordPhotosProbe(userId, id, version).catch(() => { - /* a stale lastSeenAt is not worth failing a request over */ - }); -} - -const bad = (error: string, status = 400) => Response.json({ error }, { status }); - -type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown; email?: unknown; password?: unknown }; - -const readBody = async (req: Request): Promise => - ((await req.json().catch(() => null)) as AccountBody | null) ?? {}; - -const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : ''); -const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : ''); -const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : ''); -const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : ''); -const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : ''); - -const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); - -/** - * The key to store, however the owner chose to supply it. - * - * A pasted key wins; otherwise an email and password are traded for a fresh one and the password is discarded - * with this function's stack frame. Returns '' when neither was given, which for an edit means "keep what is - * stored" and for an add is an error. - */ -async function resolveKey(body: AccountBody, base: string): Promise { - const apiKey = readKey(body); - if (apiKey) return apiKey; - - const email = readEmail(body); - const password = readPassword(body); - if (!email || !password) return ''; - - return mintApiKey(base, email, password); -} - -/** A refused sign-in already carries Immich's own words; anything else never reached Immich at all. */ -const signInError = (err: unknown): string => - err instanceof UpstreamError ? err.message : `could not reach the instance (${String(err)})`; - -const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label'); - -/** Add an account: validated against the live instance, then stored encrypted. */ -async function addAccount(req: Request, userId: number): Promise { - const body = await readBody(req); - const url = readUrl(body); - let label = readLabel(body); - - if (!url) return bad('url is required'); - if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); - - let apiKey: string; - try { - apiKey = await resolveKey(body, url); - } catch (err) { - return bad(signInError(err)); - } - if (!apiKey) return bad('an API key, or an email and password to mint one, is required'); - - const result = await probe({ base: url, key: apiKey }); - if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); - - // An unlabelled account takes the name Immich itself knows it by, which is nearly always what the owner - // would have typed. Falling back to the host keeps the switcher readable even for an anonymous key. - if (!label) label = result.user ?? new URL(url).host; - - // The first account wins the selection: a registry with rows but nothing selected reads as "not connected". - const existing = await listPhotosAccounts(userId); - const activate = existing.length === 0; - - try { - const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate }); - invalidateConfig(userId); - return Response.json({ account, user: result.user }); - } catch (err) { - if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); - throw err; - } -} - -/** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */ -async function editAccount(req: Request, userId: number, id: number): Promise { - const body = await readBody(req); - const label = readLabel(body); - const url = readUrl(body); - - const current = await getPhotosCredentials(userId, id); - if (!current) return bad('no such account', 404); - if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://'); - - let apiKey: string; - try { - apiKey = await resolveKey(body, url || current.url); - } catch (err) { - return bad(signInError(err)); - } - - // Re-validate whenever what we would talk to changes. A rename on its own never touches the instance. - let version: string | null | undefined; - if ((url && url !== current.url) || apiKey) { - const result = await probe({ base: url || current.url, key: apiKey || current.apiKey }); - if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); - version = result.version; - } - - try { - const account = await updatePhotosAccount(userId, id, { - label: label || undefined, - url: url || undefined, - apiKey: apiKey || undefined, - version, - }); - if (!account) return bad('no such account', 404); - invalidateConfig(userId); - return Response.json({ account }); - } catch (err) { - if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); - throw err; - } -} - -/** `subpath` is '' for /_config, or '/' / '//activate'. */ -export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { - const [, rawId, action] = subpath.split('/'); - - if (!rawId) { - if (req.method === 'GET') return accountList(userId); - if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId); - return bad('method not allowed', 405); - } - - const id = Number(rawId); - if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404); - - if (action === 'activate') { - if (req.method !== 'POST') return bad('method not allowed', 405); - const account = await setActivePhotosAccount(userId, id); - if (!account) return bad('no such account', 404); - invalidateConfig(userId); - return accountList(userId); - } - - if (action) return bad('not found', 404); - - if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id); - - if (req.method === 'DELETE') { - const removed = await deletePhotosAccount(userId, id); - if (!removed) return bad('no such account', 404); - invalidateConfig(userId); - return accountList(userId); - } - - return bad('method not allowed', 405); -} diff --git a/src/servers/sidecar/photos/index.ts b/src/servers/sidecar/photos/index.ts deleted file mode 100644 index 2f57bf96..00000000 --- a/src/servers/sidecar/photos/index.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { createSidecarConnector } from '../connect'; -import { handleConfigRoute, noteProbe, probe } from './config'; -import { handleLockedRoute } from './locked'; -import { handleOfficerRoute } from './routes'; -import { getConfig } from './upstream'; -import { API_URL } from '../../officer-url.mjs'; - -// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key. -// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich -// credentials. -// -// The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no -// longer read from the environment. See upstream.ts for why that move mattered. -// -// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation -// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point. -// -// Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated). -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding. -// -// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is. -// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] } -// POST /_config { label?, url, and EITHER apiKey OR email+password } — the password mints a -// key and is never stored. Validated against the instance, then stored encrypted -// PATCH /_config/:id same fields, all optional — no credential keeps the stored key -// POST /_config/:id/activate switch to that account -// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one -// * /_officer/ forwarded to /api/, first-segment allow-list (routes.ts) -// -// GET /_locked/status { hasPin, hasPassword, unlocked, expiresAt } — the private folder's state -// POST /_locked/pin { pinCode } set one up; PUT changes it, DELETE removes it (API key is enough) -// POST /_locked/unlock { password, pinCode } — signs in, elevates, holds the session IN MEMORY -// POST /_locked/lock drops elevation and closes the session -// * /_locked/api/ forwarded on that session — assets, search and timeline only (locked.ts) -// -// anything else 404 -// -// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind -// loopback only, so its presence is the trust signal — a request without it did not come through the -// platform, and the connection is per-owner data. -// -// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and -// `/api/photos/_officer/assets//thumbnail?size=preview` streams the thumbnail bytes back, Range and -// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list. -// ───────────────────────────────────────────────────────────────────────────────────────────────── - -/** Grab an ephemeral free port by briefly binding one and releasing it. */ -function getFreePort(): number { - const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); - const p = probeServer.port; - probeServer.stop(true); - if (p == null) throw new Error('failed to acquire a free port'); - return p; -} - -const port = getFreePort(); - -const server = Bun.serve({ - port, - hostname: '127.0.0.1', - // Uploads go through this proxy as multipart bodies, and a phone's video is not small. - maxRequestBodySize: 4 * 1024 * 1024 * 1024, - async fetch(req) { - const url = new URL(req.url); - - const officerUser = req.headers.get('X-Officer-User'); - const userId = Number(officerUser); - if (!officerUser || !Number.isInteger(userId) || userId <= 0) { - return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); - } - - if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { - try { - return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); - } catch (err) { - console.error(`[photos] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - const cfg = await getConfig(userId); - - // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a - // configured-but-broken instance is the whole reason the flag is on the response. - if (url.pathname === '/_health') { - if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); - - const started = Date.now(); - const result = await probe(cfg); - const ms = Date.now() - started; - - if (!result.ok) { - return Response.json( - { ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms }, - { status: 502 }, - ); - } - await noteProbe(userId, cfg.id, result.version); - return Response.json({ - ok: true, - configured: true, - account: cfg.label, - version: result.version, - user: result.user, - ms, - }); - } - - if (url.pathname.startsWith('/_officer/')) { - if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 }); - try { - const res = await handleOfficerRoute(cfg, req, url); - if (res) return res; - return Response.json({ error: 'not found' }, { status: 404 }); - } catch (err) { - console.error(`[photos] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - if (url.pathname === '/_locked' || url.pathname.startsWith('/_locked/')) { - if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 }); - try { - return await handleLockedRoute(req, cfg, userId, url.pathname.slice('/_locked'.length)); - } catch (err) { - // Never let the error text out: this surface handles a password and a PIN, and an upstream failure - // carrying either into a response body or a log line is the one leak worth being paranoid about. - console.error(`[photos] ${req.method} ${url.pathname} failed`); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - return Response.json({ error: 'not found' }, { status: 404 }); - }, -}); - -console.log(`[photos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in photos_config)`); - -type ReplyFn = (msg: SidecarEvent) => void; - -function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { - switch (cmd.type) { - case 'ping': - reply({ type: 'pong', id: cmd.id }); - break; - default: - reply({ - type: 'error', - id: (cmd as SidecarCommand).id, - error: `Unknown command type: ${(cmd as Record).type}`, - }); - } -} - -const connection = createSidecarConnector({ - apiUrl: `${API_URL}/api/sidecar/register`, - name: 'photos', - handles: ['photos'], - onCommand(cmd, reply) { - handleCommand(cmd as SidecarCommand, reply as ReplyFn); - }, - onConnected() { - connection.send({ type: 'photos:server', port }); - console.log(`[photos] reported server port ${port} to API`); - }, -}); - -function shutdown(signal: string) { - console.log(`[photos] ${signal} received, shutting down...`); - try { - server.stop(true); - } catch { - /* already stopped */ - } - connection.destroy(); - process.exit(0); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/photos/locked.ts b/src/servers/sidecar/photos/locked.ts deleted file mode 100644 index a52b970a..00000000 --- a/src/servers/sidecar/photos/locked.ts +++ /dev/null @@ -1,332 +0,0 @@ -import type { UpstreamConfig } from './upstream'; -import { relayResponse } from './routes'; -import { UpstreamError, callUpstream, login, logout } from './upstream'; - -// `/_locked` — Immich's private folder, which is the one part of the library the stored API key cannot reach. -// -// WHY THIS IS A SEPARATE SURFACE RATHER THAN A FEW MORE ENTRIES IN routes.ts -// -// Immich gates locked assets on ELEVATION, and elevation lives on a session. Verified against the running -// 3.1.0 image rather than inferred: -// -// utils/access.js requireElevatedPermission → `if (!auth.session?.hasElevatedPermission) throw` -// services/auth.service.js unlockSession → `if (!auth.session) throw 'only be used with a session token'` -// services/session.service.js create → the same guard, so a key cannot even mint a session to escape this -// repositories/access.repository.js checkOwnerAccess → `.$if(!hasElevatedPermission, visibility != locked)` -// -// An API key has no session, so `auth.session` is undefined and the check fails unconditionally. No key -// permission, scope or allow-list entry changes that. The last of those four is the one that decides the shape -// of this file: the elevation test is inside the generic owner-access check, so it covers `AssetView` and -// `AssetDownload` too — a locked asset's THUMBNAIL and ORIGINAL are gated, not merely its listings. -// -// So reading the private folder needs a session, a session needs a password, and Officer deliberately does not -// keep one (see upstream.ts: the password is used to mint a key and discarded with the stack frame). The -// resolution here is that the session is minted at unlock, held IN MEMORY for the elevation window, and closed -// on lock or idle. Nothing new is written to `photos_config`, so a full compromise of Officer's database still -// does not open the private folder — the PIN and the password are never at rest here. -// -// The cost is that unlocking asks for the Immich password as well as the PIN. That is a real cost and the -// alternative is a stored session token, which would make unlock PIN-only. It was left unbuilt because it is a -// standing security-posture change and this is not: everything below is forgotten when the process restarts. -// -// The general allow-list in routes.ts still refuses `auth/*` wholesale. This file does not widen it — it -// reaches four specific auth routes through named endpoints that each do one thing, which is the narrowing the -// request asked for rather than a door left open for anything under `auth/`. - -/** What the API key alone can do, which is more than expected and worth being precise about. */ -const PIN_PATH = '/api/auth/pin-code'; - -/** - * Immich holds elevation for 15 minutes and slides it forward while a session keeps working. We do not mirror - * that arithmetic — Immich stays the authority and a 401 collapses our copy — but a session nobody has used - * for longer than the window can never be elevated again, so it is closed rather than left open indefinitely. - */ -const IDLE_MS = 20 * 60 * 1000; - -/** Immich's own rule, checked here so a typo is a clear message instead of a zod dump from upstream. */ -const PIN_RE = /^\d{6}$/; - -type Elevation = { accountId: number; base: string; token: string; lastUsedAt: number }; - -// Keyed by Officer user. The value never leaves this module and is never persisted. -const elevations = new Map(); - -/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */ -async function drop(userId: number): Promise { - const current = elevations.get(userId); - if (!current) return; - elevations.delete(userId); - await logout(current.base, current.token); -} - -/** - * The live session for this user, or null. - * - * Switching Immich accounts while unlocked has to invalidate it: the session belongs to the account it was - * minted against, and serving the previous library's private folder after a switch would be the worst possible - * version of the caching bug the config routes already guard against. - */ -function current(userId: number, cfg: UpstreamConfig): Elevation | null { - const found = elevations.get(userId); - if (!found) return null; - - if (found.accountId !== cfg.id || Date.now() - found.lastUsedAt > IDLE_MS) { - void drop(userId); - return null; - } - return found; -} - -const bad = (error: string, status = 400) => Response.json({ error }, { status }); - -/** 423 is the signal the UI turns back into the unlock prompt; 401 would read as "signed out of Officer". */ -const locked = (error = 'the private folder is locked') => Response.json({ error, unlocked: false }, { status: 423 }); - -type Body = { pinCode?: unknown; newPinCode?: unknown; password?: unknown }; - -const readBody = async (req: Request): Promise => ((await req.json().catch(() => null)) as Body | null) ?? {}; -const str = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); - -/** Immich answers `{ message }`; anything else means we never really reached it. */ -async function message(res: Response, fallback: string): Promise { - const body = (await res.json().catch(() => null)) as { message?: unknown } | null; - return typeof body?.message === 'string' ? body.message : fallback; -} - -/** - * The email to sign in as, taken from the key rather than from the caller. - * - * The client knows the password but has no reason to know which Immich account the stored key belongs to, and - * asking it to send an email would let a wrong one turn a valid password into "sign-in failed". - */ -async function ownerEmail(cfg: UpstreamConfig): Promise { - const res = await callUpstream(cfg, { path: '/api/users/me' }); - if (!res.ok) throw new UpstreamError(await message(res, 'could not identify the account'), res.status); - - const me = (await res.json()) as { email?: unknown }; - if (typeof me.email !== 'string' || !me.email) throw new UpstreamError('the account has no email', 500); - return me.email; -} - -/** - * `GET /_locked/status` — whether a PIN exists, and whether this user is currently unlocked. - * - * Answered through the session when there is one, because only then does Immich report `isElevated` and - * `pinExpiresAt` truthfully; the API key can still answer the two questions that do not involve a session. - * `hasPassword` is here so a password-less OAuth account can be told that unlocking is impossible rather than - * being handed a form that cannot work. - */ -async function status(cfg: UpstreamConfig, userId: number): Promise { - const session = current(userId, cfg); - const res = await callUpstream(cfg, { path: '/api/auth/status', bearer: session?.token }); - if (!res.ok) return bad(await message(res, 'could not read the account status'), res.status); - - const info = (await res.json()) as { - pinCode?: boolean; - password?: boolean; - isElevated?: boolean; - pinExpiresAt?: string; - }; - - // Immich disagreeing with us is Immich winning: the window lapsed underneath a session we still hold. - if (session && !info.isElevated) await drop(userId); - - return Response.json({ - hasPin: !!info.pinCode, - hasPassword: !!info.password, - unlocked: !!info.isElevated, - expiresAt: info.pinExpiresAt ?? null, - }); -} - -/** - * `POST /_locked/unlock` — sign in, elevate, keep the session. - * - * A wrong PIN has to close the session it just opened. Immich lists every sign-in as a device, so leaking one - * per failed attempt would turn a mistyped PIN into a growing list of phantom devices in the owner's account. - */ -async function unlock(req: Request, cfg: UpstreamConfig, userId: number): Promise { - const body = await readBody(req); - const password = typeof body.password === 'string' ? body.password : ''; - const pinCode = str(body.pinCode); - - if (!password) return bad('the Immich account password is required to unlock'); - if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits'); - - let token: string; - try { - token = await login(cfg.base, await ownerEmail(cfg), password); - } catch (err) { - if (err instanceof UpstreamError) return bad(err.message, err.status === 401 ? 401 : 400); - return bad(`could not reach the instance (${String(err)})`, 502); - } - - const res = await callUpstream(cfg, { - path: '/api/auth/session/unlock', - method: 'POST', - bearer: token, - body: JSON.stringify({ pinCode }), - contentType: 'application/json', - }); - if (!res.ok) { - await logout(cfg.base, token); - return bad(await message(res, 'the PIN was rejected'), res.status === 400 ? 400 : res.status); - } - - // Replace rather than accumulate: unlocking twice should not leave the first session open. - await drop(userId); - elevations.set(userId, { accountId: cfg.id, base: cfg.base, token, lastUsedAt: Date.now() }); - - return Response.json({ unlocked: true }); -} - -/** `POST /_locked/lock` — give up elevation immediately rather than waiting for it to lapse. */ -async function lock(cfg: UpstreamConfig, userId: number): Promise { - const session = current(userId, cfg); - if (session) { - await callUpstream(cfg, { path: '/api/auth/session/lock', method: 'POST', bearer: session.token }); - await drop(userId); - } - return Response.json({ unlocked: false }); -} - -/** - * `/_locked/pin` — set up, change and remove the PIN, all on the API key. - * - * These genuinely do not need a session: `setupPinCode` and `validatePinCode` read `auth.user.id` and never - * touch `auth.session` (auth.service.js), so the key is enough. That is what makes the private folder - * something Officer can offer end to end instead of sending the owner to Immich's own settings screen first. - */ -async function pin(req: Request, cfg: UpstreamConfig, userId: number): Promise { - const body = await readBody(req); - const pinCode = str(body.pinCode); - const newPinCode = str(body.newPinCode); - const password = typeof body.password === 'string' ? body.password : ''; - - const call = (method: string, payload: Record) => - callUpstream(cfg, { path: PIN_PATH, method, body: JSON.stringify(payload), contentType: 'application/json' }); - - if (req.method === 'POST') { - if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits'); - const res = await call('POST', { pinCode }); - return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not set the PIN'), res.status); - } - - // Change and reset both prove intent with the OLD pin or the account password, so the two are read together. - const proof: Record | null = password ? { password } : pinCode ? { pinCode } : null; - if (!proof) return bad('the current PIN, or the account password, is required'); - - if (req.method === 'PUT') { - if (!PIN_RE.test(newPinCode)) return bad('the new PIN is six digits'); - const res = await call('PUT', { ...proof, newPinCode }); - return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not change the PIN'), res.status); - } - - const res = await call('DELETE', proof); - if (!res.ok) return bad(await message(res, 'could not remove the PIN'), res.status); - - // Immich drops elevation from every session when the PIN is reset; our copy has to go with it. - await drop(userId); - return Response.json({ hasPin: false, unlocked: false }); -} - -/** - * The elevated forward, deliberately a fraction of the main allow-list. - * - * Three resources, because three are enough to see the folder and move things in and out of it: read the - * assets and their bytes, list them, and change their visibility. DELETE is absent on purpose — emptying the - * trash or destroying an asset is reachable on the ordinary surface once it is out of the folder, and an - * irreversible route is not worth putting behind a credential the owner cannot see. - */ -const ELEVATED: Record = { - assets: ['GET', 'PUT'], - search: ['POST'], - timeline: ['GET'], -}; - -/** `/_locked/api/` — the same shape as `/_officer/`, but on the session and far narrower. */ -async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise { - // The allow-list is checked BEFORE the session, and the order is deliberate. A resource this surface will - // never carry has to say so whether or not the folder happens to be open — answering 423 would invite a - // client to prompt for a password and a PIN to reach something that stays a 404 afterwards. - const [resource] = rest.split('/'); - const allowed = resource ? ELEVATED[resource] : undefined; - if (!allowed) return Response.json({ error: 'not found' }, { status: 404 }); - if (!allowed.includes(req.method)) return bad(`${req.method} not allowed on ${resource}`, 405); - - const session = current(userId, cfg); - if (!session) return locked(); - - // Officer's own JWT arrives in `?token=` because an cannot send a header. Forwarding it would write the - // owner's session credential into Immich's access log for every locked thumbnail. - const query = new URLSearchParams(url.search); - query.delete('token'); - const search = query.toString(); - - const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; - const res = await callUpstream(cfg, { - path: `/api/${rest}`, - method: req.method, - query: search ? `?${search}` : '', - // Streamed, not buffered — same reason as the unlocked forwarder in routes.ts. - body: hasBody ? req.body : null, - contentType: req.headers.get('content-type'), - range: req.headers.get('range'), - ifNoneMatch: req.headers.get('if-none-match'), - bearer: session.token, - }); - - // The window closed underneath us. Collapse to locked so the UI prompts again instead of showing an error. - if (res.status === 401) { - await drop(userId); - return locked('the private folder locked itself again'); - } - - session.lastUsedAt = Date.now(); - return relayResponse(res); -} - -/** `subpath` is the part after `/_locked`, e.g. '/status' or '/api/assets//thumbnail'. */ -export async function handleLockedRoute( - req: Request, - cfg: UpstreamConfig, - userId: number, - subpath: string, -): Promise { - const url = new URL(req.url); - const [, head, ...tail] = subpath.split('/'); - - if (head === 'api') return forward(req, url, cfg, userId, tail.join('/')); - - if (head === 'status') { - return req.method === 'GET' ? status(cfg, userId) : bad('method not allowed', 405); - } - - if (head === 'unlock') { - return req.method === 'POST' ? unlock(req, cfg, userId) : bad('method not allowed', 405); - } - - if (head === 'lock') { - return req.method === 'POST' ? lock(cfg, userId) : bad('method not allowed', 405); - } - - if (head === 'pin') { - const allowed = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE'; - return allowed ? pin(req, cfg, userId) : bad('method not allowed', 405); - } - - return Response.json({ error: 'not found' }, { status: 404 }); -} - -/** - * Close sessions nobody came back for. - * - * Without this an owner who unlocks and then closes the tab leaves a signed-in device in their Immich account - * until the sidecar restarts. Elevation is long gone by then, so the session is useless to us and untidy there. - */ -setInterval(() => { - const now = Date.now(); - for (const [userId, session] of elevations) { - if (now - session.lastUsedAt > IDLE_MS) void drop(userId); - } -}, 60_000).unref(); diff --git a/src/servers/sidecar/photos/routes.ts b/src/servers/sidecar/photos/routes.ts deleted file mode 100644 index d0aeabf6..00000000 --- a/src/servers/sidecar/photos/routes.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { UpstreamConfig } from './upstream'; -import { callUpstream } from './upstream'; - -// The Officer-owned route surface. This is an allow-list, not a passthrough. -// -// Immich's API is already REST and already shaped the way a photo UI wants it, so the value this sidecar -// adds is (a) holding the API key, (b) streaming the binary routes through without buffering them, and -// (c) refusing to expose the administrative half of the API at all. -// -// The gate is the FIRST path segment plus the method. Anything whose first segment is not listed here is a -// 404 before a request is made, so the deny side is closed by construction rather than by enumeration. -// -// DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and -// password machinery — the key is the credential here, and nothing should be minting sessions through a -// dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/* -// -// `auth/*` is STILL refused here, and locked.ts does not change that. Immich's private folder needs four auth -// routes (`auth/status`, `auth/pin-code`, `auth/session/unlock`, `auth/session/lock`), and they are reached -// through named endpoints on a separate `/_locked` surface that each do one thing — not by making `auth` a -// resource anything may address. The distinction is the point: the decision that was taken was "the sidecar -// may unlock the private folder", not "the sidecar may reach session and password machinery". -// (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external -// library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it. -// Adding one should be a decision, not an accident. - -const RESOURCES: Record = { - // The library itself. - assets: ['GET', 'POST', 'PUT', 'DELETE'], - albums: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - timeline: ['GET'], - memories: ['GET', 'POST', 'PUT', 'DELETE'], - people: ['GET', 'POST', 'PUT'], - faces: ['GET', 'PUT'], - tags: ['GET', 'POST', 'PUT', 'DELETE'], - stacks: ['GET', 'POST', 'PUT', 'DELETE'], - // Finding things. `search/metadata`, `search/smart` and `search/random` are POSTs with a JSON body. - search: ['GET', 'POST'], - duplicates: ['GET'], - map: ['GET'], - view: ['GET'], - // Getting things out. `download/info` then `download/archive` — both POST, the archive streams a zip. - download: ['POST'], - // Recoverable deletes. `trash/empty` is the one irreversible route in this list; it is here because it is - // the counterpart of a delete the UI can already perform, not because it is safe. - trash: ['GET', 'POST'], - // Sharing and social. Read-mostly, but a shared link is the point of them. - 'shared-links': ['GET', 'POST', 'PATCH', 'DELETE'], - activities: ['GET', 'POST', 'DELETE'], - partners: ['GET'], - notifications: ['GET', 'PUT', 'DELETE'], - // Read-only context: who the key acts as, and what the server is. - users: ['GET'], - server: ['GET'], -}; - -// Response headers worth carrying back. Content-Type and Content-Length for every response; the rest so a -// thumbnail can be cached and revalidated and a video can be seeked without the proxy understanding either. -const PASSTHROUGH_HEADERS = [ - 'content-type', - 'content-length', - 'content-disposition', - 'content-range', - 'accept-ranges', - 'etag', - 'last-modified', - 'cache-control', -] as const; - -/** - * Hand one Immich response back to the caller, keeping only the headers above. - * - * Shared with the locked-folder surface so both forwarders stream rather than buffer and agree on what a - * thumbnail is allowed to carry back. 204 and 304 must not have a body attached — Bun throws if one is. - */ -export function relayResponse(res: Response): Response { - const headers = new Headers(); - for (const name of PASSTHROUGH_HEADERS) { - const value = res.headers.get(name); - if (value) headers.set(name, value); - } - - const body = res.status === 204 || res.status === 304 ? null : res.body; - return new Response(body, { status: res.status, headers }); -} - -/** - * The largest request body held in memory. Above this the body is STREAMED instead — never refused. - * - * The cap exists to bound heap, not to bound uploads: a 4 GB video must still go through. What is given - * up above the cap is only the guarantee that a REJECTION carries its message, and an upload that big - * which Immich rejects was going to fail either way — see `readBodyCapped`. - */ -const MAX_BUFFERED_BODY = 512 * 1024 * 1024; - -/** - * Read the whole request body into memory, refusing above `MAX_BUFFERED_BODY`. - * - * ── Why this is buffered rather than streamed ── - * - * It used to stream, which is the obvious thing for an upload and was wrong for one specific reason: - * when the body is still being written and Immich answers early, the response is lost. Immich rejects - * an asset as soon as it has enough of the file to judge it, then closes; our writer is still going; - * Node's HTTP server calls the leftover bytes a protocol violation and replaces the app's answer with a - * bodyless `400 Bad Request` + `Connection: close`. The app's real message never reaches the wire and - * Immich logs nothing, so the client sees an unexplained 400 and the server has no record of it. - * - * Measured on this machine (COMMS/UPLOAD_EMPTY_400*.md): streamed lost the response in 2 of 4 attempts - * at 8 MB, 32 MB and 64 MB alike; buffered lost it in 0 of 4 at every size. It is a race, not a size - * limit — its probability just approaches certainty as the body grows, which is why a phone's video - * failed every time while a small photo usually got through. - * - * A size threshold was considered and rejected: buffering only the small bodies would leave exactly the - * large uploads that always fail on the broken path. Cancelling the write once the response arrives was - * tried and does not help — by the time `fetch` resolves, the teardown has already happened. - * - * The cost is real and was accepted deliberately: a 300 MB video is 300 MB of this process's heap for - * the duration of the upload. That is the price of the upload working at all. - * - * ── And why there is a ceiling on it ── - * - * Buffering everything would mean a 4 GB video sitting in this heap (and another copy in officer's), so - * above MAX_BUFFERED_BODY the body is streamed as it always was. That is not a refusal and not a - * degradation of uploads: a file Immich ACCEPTS never triggers the race, because it is read to the end - * before anything is answered. The only thing lost above the cap is the error message on a file Immich - * rejects — and an upload that size which Immich rejects fails either way. Refusing it outright would - * break the legitimate case to improve the diagnosis of the doomed one. - */ -async function readBodyCapped( - req: Request, -): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; seen: number }> { - // Decided from `content-length` alone. Officer sends one whenever IT buffered, so the two hops agree - // without coordinating: a body small enough for officer to hold is small enough to hold here. When the - // header is absent officer streamed, which means it was over the cap there too. - const declared = Number(req.headers.get('content-length') ?? ''); - if (!Number.isFinite(declared) || declared <= 0 || declared > MAX_BUFFERED_BODY) return { ok: false, seen: declared }; - - const reader = req.body!.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.length; - if (total > MAX_BUFFERED_BODY) { - await reader.cancel().catch(() => {}); - return { ok: false, seen: total }; - } - chunks.push(value); - } - - const bytes = new Uint8Array(new ArrayBuffer(total)); - let at = 0; - for (const chunk of chunks) { - bytes.set(chunk, at); - at += chunk.length; - } - return { ok: true, bytes }; -} - -/** - * Forward one request to Immich and stream the answer back. - * - * The RESPONSE is still a stream, never an ArrayBuffer: a full-resolution original or a - * `download/archive` zip is hundreds of megabytes and buffering it would hold all of it in the - * sidecar's heap for no reason. Only the request body is held, and only for the reason above. - */ -export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise { - const rest = url.pathname.slice('/_officer/'.length); - if (!rest) return null; - - const [resource] = rest.split('/'); - const allowedMethods = resource ? RESOURCES[resource] : undefined; - if (!allowedMethods) return null; - if (!allowedMethods.includes(req.method)) { - return Response.json({ error: `${req.method} not allowed on ${resource}` }, { status: 405 }); - } - - const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; - - // An /