Files
photos/sidecar/index.ts
T
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:39 +00:00

184 lines
8.2 KiB
TypeScript

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/<path> forwarded to <active url>/api/<path>, 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/<path> 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/<id>/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<string, unknown>).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'));