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>
184 lines
8.2 KiB
TypeScript
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'));
|