From 2dad1907f8289f8c073961920a27f67c8b9900d5 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 18 Aug 2026 19:10:39 +0000 Subject: [PATCH] photos, extracted from the platform into its own repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifth plugin out, and the cheapest so far — offscale's shape rather than music's or vault's. Nothing stayed behind and nothing had to be redesigned, for three structural reasons recorded in PLUGIN.md: • No reverse dependency. Nothing in src/ imported anything of photos'. • Configuration was already right. The URL and API key live in photos_config, a per-user registry encrypted at rest and driven from the app. Vault had to move VAULTWARDEN_URL out of .env and invent a settings panel; here the sidecar already read the database, and its own comment already said why nothing reads process.env. • The frontend was already panels. The screen was a WorkspaceView around exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are the screen's contents with the wrapper removed. The router's prefix is now derived from mountPrefix rather than the literal '/api/photos', which was correct only by coincidence — a third-party publisher mounts at /api/p//photos and the literal would forward the wrong path to the sidecar. Palette debt paid down as the runbook requires: 6 classes (it says 47; someone had already done most of it), all of them state colours wearing palette numbers, now `success` and `warning` from the closed tone set so they follow the theme. Immich itself is still provisioned by the app store rather than by this plugin — the `provisioned` half of its old catalogue entry has no home until Docker provisioning for plugins exists. Point it at an instance you already run. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + PLUGIN.md | 69 ++++++ README.md | 40 ++++ api/router.ts | 29 +++ db/queries.ts | 177 +++++++++++++++ db/schema.ts | 49 +++++ manifest.ts | 39 ++++ sidecar/config.ts | 236 ++++++++++++++++++++ sidecar/index.ts | 183 +++++++++++++++ sidecar/locked.ts | 332 ++++++++++++++++++++++++++++ sidecar/routes.ts | 207 +++++++++++++++++ sidecar/upstream.ts | 227 +++++++++++++++++++ web/AccountSwitcher.tsx | 82 +++++++ web/AlbumsSection.tsx | 172 +++++++++++++++ web/AssetInfoPanel.tsx | 101 +++++++++ web/AssetTile.tsx | 107 +++++++++ web/AssetViewer.tsx | 254 +++++++++++++++++++++ web/ConnectionSection.tsx | 367 ++++++++++++++++++++++++++++++ web/ExploreSection.tsx | 94 ++++++++ web/MapClusterPanel.tsx | 123 +++++++++++ web/MapSection.tsx | 453 ++++++++++++++++++++++++++++++++++++++ web/PeopleSection.tsx | 129 +++++++++++ web/PhotoGrid.tsx | 56 +++++ web/PhotosNav.tsx | 95 ++++++++ web/PhotosView.tsx | 65 ++++++ web/PhotosViewHeader.tsx | 17 ++ web/SearchSection.tsx | 114 ++++++++++ web/SelectionBar.tsx | 121 ++++++++++ web/SharingSection.tsx | 101 +++++++++ web/TimelineGrid.tsx | 145 ++++++++++++ web/TimelineSection.tsx | 95 ++++++++ web/layout.ts | 11 + web/panels.ts | 22 ++ web/shared.ts | 400 +++++++++++++++++++++++++++++++++ web/useAssetActions.ts | 136 ++++++++++++ web/useContainerWidth.ts | 28 +++ web/usePhotosData.ts | 368 +++++++++++++++++++++++++++++++ web/usePhotosSection.ts | 10 + web/useSelection.ts | 65 ++++++ 39 files changed, 5321 insertions(+) create mode 100644 .gitignore create mode 100644 PLUGIN.md create mode 100644 README.md create mode 100644 api/router.ts create mode 100644 db/queries.ts create mode 100644 db/schema.ts create mode 100644 manifest.ts create mode 100644 sidecar/config.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/locked.ts create mode 100644 sidecar/routes.ts create mode 100644 sidecar/upstream.ts create mode 100644 web/AccountSwitcher.tsx create mode 100644 web/AlbumsSection.tsx create mode 100644 web/AssetInfoPanel.tsx create mode 100644 web/AssetTile.tsx create mode 100644 web/AssetViewer.tsx create mode 100644 web/ConnectionSection.tsx create mode 100644 web/ExploreSection.tsx create mode 100644 web/MapClusterPanel.tsx create mode 100644 web/MapSection.tsx create mode 100644 web/PeopleSection.tsx create mode 100644 web/PhotoGrid.tsx create mode 100644 web/PhotosNav.tsx create mode 100644 web/PhotosView.tsx create mode 100644 web/PhotosViewHeader.tsx create mode 100644 web/SearchSection.tsx create mode 100644 web/SelectionBar.tsx create mode 100644 web/SharingSection.tsx create mode 100644 web/TimelineGrid.tsx create mode 100644 web/TimelineSection.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts create mode 100644 web/shared.ts create mode 100644 web/useAssetActions.ts create mode 100644 web/useContainerWidth.ts create mode 100644 web/usePhotosData.ts create mode 100644 web/usePhotosSection.ts create mode 100644 web/useSelection.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..ffeae76 --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,69 @@ +# Photos — the extraction record + +Extracted from the platform on **2026-08-18**, the fifth plugin out (after offscale, music, email, vault). +`plugins/EXTRACTING-A-PLUGIN.md` is the runbook; this file is what it cost. + +## The clean case + +Offscale's shape rather than music's or vault's: **nothing stayed behind, and nothing had to be +redesigned.** Worth recording why, because it is the cheapest extraction so far and the reasons are +structural rather than luck. + +- **No reverse dependency.** Nothing in `src/` imported anything of photos'. The whole platform surface + was one commented router import, one barrel line, and the ordinary wiring (route, screen, registry, + dock rule, catalogue entry). Compare vault, whose extraction had to invent an account-lifecycle hook + because three core auth handlers called into it. +- **Configuration was already right.** The URL and API key live in `photos_config` — a per-user registry + with one active row, encrypted at rest, driven from the app. Vault had to move `VAULTWARDEN_URL` out of + `.env` and invent a settings panel; here the sidecar already read the database and its own comment + already said why nothing reads `process.env`: an `IMMICH_API_KEY` there would sit in the environment of + every pm2 process, including the one with the largest attack surface and no code to use it. +- **The frontend was already panels.** Two of them, `photos-nav` and `photos-view`, plus a layout. The + screen was a `WorkspaceView` around exactly those, so `web/panels.ts` and `web/layout.ts` are the + screen's own contents with the wrapper removed. + +## What moved + +| Was | Is now | +| ------------------------------------------ | ------------------- | +| `src/servers/sidecar/photos/*` (5 files) | `sidecar/*` | +| `src/servers/api/photos/router.ts` | `api/router.ts` | +| `src/databases/officer_db/src/photos/*` | `db/*` | +| `src/workspaces/officerdev/src/apps/Photos/*` (25 files) | `web/*` | +| `src/apps/officer-web/Screens/Dashboard/Photos/*` | `web/layout.ts` | + +The prefix in `api/router.ts` is now DERIVED from `mountPrefix` rather than the literal `/api/photos`, +which was right only by coincidence — see the comment there, and `plugins/music/api/router.ts` for the +same fix and the same reasoning. + +## What the platform lost + +Deleted, not commented: the sidecar, the api router, the db directory, the Photos app, the screen, the +`App.tsx` routes, 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` +comment, and the `app-store/catalogue.ts` entry. + +**The catalogue deletion is required, not 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`, puts `/photos` into `deniedRoutes` and +withholds the dock tile, on a server where the plugin is installed and working. + +## Two things paid down on the way + +**Palette classes.** The runbook says to pay these down as part of the extraction rather than exporting +colour debt into a repository nobody revisits, and it puts Photos at 47. It was **6** — someone had +already done most of it. All six were state colours wearing palette numbers (`text-emerald-400` for the +app's own tile, `text-emerald-500`/`text-amber-500` on a connection check) and are now `success` and +`warning` from the closed tone set, so they follow the theme. + +**Test fixtures.** Photos was the canonical app-store fixture across four test files, chosen for being +`existing|provisioned` with `members: 'accounts'`. Repointed at `jellyfin`, which has the identical +shape, rather than deleted — the assertions are about the installer, not about photos. + +## Still open + +- **Immich is still provisioned by the app store**, not by this plugin. `modes: ['existing', + 'provisioned']` and a compose template lived in the catalogue entry that had to be deleted, so the + `provisioned` half has no home right now: you point it at an instance you already run. Docker + provisioning for plugins is a design still in progress, and this is one of the features waiting for it. +- **No `assets/icon.png`.** Falls back to the `Images` glyph, which is what the catalogue entry used. diff --git a/README.md b/README.md new file mode 100644 index 0000000..633b450 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Photos + +Your Immich library, reached through Officer. + +Photos does not store photographs and does not install Immich. You point it at an instance you already +run; it forwards to it and holds the API key server-side, so no device ever carries one. Your library, +your originals and every thumbnail stay exactly where they are. + +## What you get + +- **The whole library** — timeline, albums, people, places, sharing and search, as panels inside Officer. +- **A map** of everything with coordinates, clustered. +- **Upload from the phone**, through Officer rather than to Immich directly. +- **More than one instance.** Accounts are a per-user registry with a label, a URL and a key; one is + active and you switch between them in the app. + +## What you need + +**An Immich you already run**, and an API key from it (Immich → Account Settings → API Keys). + +Create the key with **all permissions**. A scoped key returns 403 per route, which does not read as a +permissions problem — it reads as a broken feature, one screen at a time. + +Nothing else. No system packages, no host binaries. + +## Setup + +1. Install the plugin. +2. Open **/photos → Connection** and add an instance: a label, its URL, and the API key. +3. It probes the server, records its version, and marks the account active. + +## What it deliberately does not do yet + +- **It is a client, not a backup.** Nothing is copied to Officer. If your Immich goes away, so does the + library — this plugin never held it. +- **The key is write-only across the boundary.** The account list reports label, URL and which is active, + and has no field that could carry a key, masked or otherwise. The only way to change one is to replace + it. That is deliberate, and it means the app cannot show you a key you have forgotten. +- **Editing lives in Immich.** This browses, searches, uploads and organises into albums; it is not a + photo editor and will not become one. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..136c7df --- /dev/null +++ b/api/router.ts @@ -0,0 +1,29 @@ +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; + +// /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. +// +// ── The prefix is DERIVED, not written ── +// +// It was the literal '/api/photos', which is right only by coincidence: `mountPrefix` returns `/photos` +// for a first-party publisher, and the proxy strips exactly `prefix.length` characters to build the +// sidecar's path. Published by anyone else this mounts at `/api/p//photos` and a literal would +// forward `/alice/photos/assets` to a sidecar expecting `/assets`. Same reasoning, and the same fix, as +// `plugins/music/api/router.ts`. +const proxy = createSidecarProxy({ + name: 'photos', + prefix: `/api${mountPrefix({ appName: 'photos', manifest })}`, + // 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/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 router = proxy.router; diff --git a/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..b9b01f3 --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,177 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { db } from 'officerdb/db'; +import { photosConfig } from './schema'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; + +// Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from +// at-rest ciphertext happens here. See ../crypto.ts and ../schema/photos.ts. +// +// Two return types, and the split is the safety property: +// PhotosAccount — safe to serialize to the browser. Has NO api key field at all, not even a masked one. +// PhotosCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned by a route. +// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list +// response the moment someone forgot to strip it. + +export type PhotosAccount = { + id: number; + label: string; + url: string; + version: string | null; + isActive: boolean; + lastSeenAt: Date | null; + createdAt: Date; +}; + +export type PhotosCredentials = { id: number; label: string; url: string; apiKey: string }; + +const accountCols = { + id: photosConfig.id, + label: photosConfig.label, + url: photosConfig.url, + version: photosConfig.version, + isActive: photosConfig.isActive, + lastSeenAt: photosConfig.lastSeenAt, + createdAt: photosConfig.createdAt, +}; + +/** Every account the owner has added, active first then newest. Never includes the API key. */ +export async function listPhotosAccounts(userId: number): Promise { + 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/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..47e89d5 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,49 @@ +import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from 'officerdb/auth/schema'; + +// The Immich accounts behind /photos, for the officer-photos sidecar. +// +// This used to be IMMICH_URL + IMMICH_API_KEY in the platform-wide `.env`, which was wrong twice over: Bun +// auto-loads `.env` into EVERY process started in the platform directory, so `officer` itself held an Immich +// credential it has no code to use — and connecting a photo library was a shell task on the server rather +// than something the owner could do from the app. +// +// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the +// same shape offscale_servers uses. Two accounts on the same instance is the normal case (one key per +// Immich user), which is why the uniqueness below is on the label and not on the URL. +// +// `api_key` is encrypted at rest via ../crypto.ts. An Immich key can read and delete the entire library, so a +// DB dump must not hand it over. Encryption is confined to queries/photos.ts; nothing outside that file sees +// ciphertext, and no route ever returns the key at all. +export const photosConfig = pgTable( + 'photos_config', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */ + label: text('label').notNull(), + // Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator. + url: text('url').notNull(), + apiKey: text('api_key').notNull(), // encrypted + /** Immich version seen at the last successful probe — shown in the UI, never used for behaviour. */ + version: text('version'), + isActive: boolean('is_active').notNull().default(false), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // Labels are how the owner tells two accounts apart — duplicates would make the switcher useless. Not + // unique on url: several keys against one instance is the whole point. + uniqueIndex('uq_photos_config_user_label').on(t.userId, t.label), + // At most one active account per owner, enforced by the DB rather than by convention: a partial unique + // index over the active rows only. setActivePhotosAccount still clears the others in a transaction, but a + // bug there fails loudly here instead of silently leaving two accounts active and the UI picking one. + uniqueIndex('uq_photos_config_one_active') + .on(t.userId) + .where(sql`${t.isActive}`), + ], +); diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..a8efef9 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,39 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Photos — your Immich library, reached through Officer. + * + * ── What this plugin is, and what it is not ── + * + * It does NOT store photographs, and it does not install Immich. You point it at an instance you already + * run and it forwards to it, holding the API key server-side so no device ever carries one. The library, + * the originals and every thumbnail stay where they are. + * + * The account registry is per-user and already server-side (`photos_config`): a label, a URL and an + * encrypted API key, with one marked active. Adding and switching instances happens in the app rather + * than in a shell, which is why nothing here reads `process.env` — an `IMMICH_API_KEY` in `.env` would + * also sit in the environment of every other pm2 process, including the one with the largest attack + * surface and no code to use it. + * + * ── No `osDependencies` ── + * + * Nothing shells out. The sidecar is a Bun HTTP proxy and reaches Immich with `fetch`. + */ +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + + label: 'Photos', + summary: 'Your Immich library — browse, search, upload from the phone', + icon: 'Images', + color: '#10b981', + + permissions: [ + { + key: 'photos', + label: 'Photos', + description: 'Browse the photo library', + }, + ], +}; diff --git a/sidecar/config.ts b/sidecar/config.ts new file mode 100644 index 0000000..efe8283 --- /dev/null +++ b/sidecar/config.ts @@ -0,0 +1,236 @@ +import type { UpstreamTarget } from './upstream'; +import { + createPhotosAccount, + deletePhotosAccount, + getPhotosCredentials, + listPhotosAccounts, + recordPhotosProbe, + setActivePhotosAccount, + updatePhotosAccount, +} from '../db/queries'; +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/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..ada9ac2 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,183 @@ +import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; +import { createSidecarConnector } from '@@/sidecar/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/sidecar/locked.ts b/sidecar/locked.ts new file mode 100644 index 0000000..a52b970 --- /dev/null +++ b/sidecar/locked.ts @@ -0,0 +1,332 @@ +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/sidecar/routes.ts b/sidecar/routes.ts new file mode 100644 index 0000000..d0aeabf --- /dev/null +++ b/sidecar/routes.ts @@ -0,0 +1,207 @@ +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 /