From a327dfc82c586c8a6714e2c794e24072856dd11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 18 Aug 2026 19:10:52 +0000 Subject: [PATCH] =?UTF-8?q?photos,=20extracted=20into=20plugins/photos=20?= =?UTF-8?q?=E2=80=94=20the=20platform=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/apps/officer-web/App.tsx | 2 - .../Screens/Dashboard/Photos/PhotosScreen.tsx | 33 -- .../Screens/Dashboard/Photos/defaultLayout.ts | 11 - .../Screens/Dashboard/Photos/index.tsx | 1 - .../officer-web/Screens/Dashboard/index.tsx | 1 - src/apps/officer-web/state/usePageTitle.ts | 1 - src/databases/officer_db/src/index.ts | 1 - src/databases/officer_db/src/photos/index.ts | 12 - .../officer_db/src/photos/queries.ts | 177 ------- src/databases/officer_db/src/photos/schema.ts | 49 -- src/databases/officer_db/src/schema.ts | 1 - src/servers/api/photos/router.ts | 25 - src/servers/app-store/catalogue.test.ts | 2 +- src/servers/app-store/catalogue.ts | 27 +- src/servers/app-store/installer.test.ts | 30 +- src/servers/app-store/members.test.ts | 6 +- src/servers/app-store/preflight.test.ts | 4 +- src/servers/hono.ts | 1 - src/servers/permissions/registry.ts | 9 - src/servers/plugins/marketplace.ts | 20 + src/servers/sidecar/photos/config.ts | 236 --------- src/servers/sidecar/photos/index.ts | 183 ------- src/servers/sidecar/photos/locked.ts | 332 ------------- src/servers/sidecar/photos/routes.ts | 207 -------- src/servers/sidecar/photos/upstream.ts | 227 --------- .../src/AppRegistry/AppRegistry.tsx | 2 - .../src/apps/Photos/AccountSwitcher.tsx | 82 ---- .../src/apps/Photos/AlbumsSection.tsx | 172 ------- .../src/apps/Photos/AssetInfoPanel.tsx | 101 ---- .../officerdev/src/apps/Photos/AssetTile.tsx | 107 ----- .../src/apps/Photos/AssetViewer.tsx | 254 ---------- .../src/apps/Photos/ConnectionSection.tsx | 367 -------------- .../src/apps/Photos/ExploreSection.tsx | 94 ---- .../src/apps/Photos/MapClusterPanel.tsx | 123 ----- .../officerdev/src/apps/Photos/MapSection.tsx | 453 ------------------ .../src/apps/Photos/PeopleSection.tsx | 129 ----- .../officerdev/src/apps/Photos/PhotoGrid.tsx | 56 --- .../officerdev/src/apps/Photos/PhotosNav.tsx | 95 ---- .../officerdev/src/apps/Photos/PhotosView.tsx | 65 --- .../src/apps/Photos/PhotosViewHeader.tsx | 17 - .../src/apps/Photos/SearchSection.tsx | 114 ----- .../src/apps/Photos/SelectionBar.tsx | 121 ----- .../src/apps/Photos/SharingSection.tsx | 101 ---- .../src/apps/Photos/TimelineGrid.tsx | 145 ------ .../src/apps/Photos/TimelineSection.tsx | 95 ---- .../officerdev/src/apps/Photos/index.ts | 19 - .../officerdev/src/apps/Photos/shared.ts | 400 ---------------- .../src/apps/Photos/useAssetActions.ts | 136 ------ .../src/apps/Photos/useContainerWidth.ts | 28 -- .../src/apps/Photos/usePhotosData.ts | 368 -------------- .../src/apps/Photos/usePhotosSection.ts | 10 - .../src/apps/Photos/useSelection.ts | 65 --- src/workspaces/officerdev/src/index.ts | 4 - 53 files changed, 47 insertions(+), 5274 deletions(-) delete mode 100644 src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Photos/defaultLayout.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Photos/index.tsx delete mode 100644 src/databases/officer_db/src/photos/index.ts delete mode 100644 src/databases/officer_db/src/photos/queries.ts delete mode 100644 src/databases/officer_db/src/photos/schema.ts delete mode 100644 src/servers/api/photos/router.ts delete mode 100644 src/servers/sidecar/photos/config.ts delete mode 100644 src/servers/sidecar/photos/index.ts delete mode 100644 src/servers/sidecar/photos/locked.ts delete mode 100644 src/servers/sidecar/photos/routes.ts delete mode 100644 src/servers/sidecar/photos/upstream.ts delete mode 100644 src/workspaces/officerdev/src/apps/Photos/AccountSwitcher.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/AlbumsSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/AssetInfoPanel.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/AssetTile.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/AssetViewer.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/ExploreSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/MapClusterPanel.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/MapSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/PeopleSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/PhotoGrid.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/PhotosNav.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/PhotosView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/PhotosViewHeader.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/SearchSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/SelectionBar.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/SharingSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/TimelineGrid.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/TimelineSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Photos/index.ts delete mode 100644 src/workspaces/officerdev/src/apps/Photos/shared.ts delete mode 100644 src/workspaces/officerdev/src/apps/Photos/useAssetActions.ts delete mode 100644 src/workspaces/officerdev/src/apps/Photos/useContainerWidth.ts delete mode 100644 src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts delete mode 100644 src/workspaces/officerdev/src/apps/Photos/usePhotosSection.ts delete mode 100644 src/workspaces/officerdev/src/apps/Photos/useSelection.ts 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 /