Receiving users:list updated an in-memory array and did nothing else, so a person added to Officer next month reached Photos only if somebody remembered to re-run provisioning by hand. The platform already broadcasts on every create and delete, and re-sends on every sidecar registration — nothing was listening. Now it acts on it. A restart is therefore also a reconciliation, which is the cheap way to recover from any push that was missed while the sidecar was down. An automatic run is NOT allowed to key an adopted account. Keying one means resetting its password, because that is the only way Immich issues a key, and for an account that pre-dated Officer that takes away a login somebody is using — most likely in Immich's own app. Doing that unattended, to a person who did not know it was at stake, is not the same act as the owner choosing it: so the account is linked, left keyless, and named in `pending`. The deliberate /provision route still does the whole job. Tested against the case this is for: jrg.conceicao has an Immich library and no Officer account today. When that account exists, the plan adopts the library rather than building a second one beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
9.3 KiB
TypeScript
199 lines
9.3 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 { setProvisionableUsers } from './user-list';
|
|
import { provisionForAccountChange } from './provision-run';
|
|
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
|
|
// POST /_config/:id/provision create/link an Immich account per Officer user and mint each their own
|
|
// key. Replies with a one-time password per person for THEIR Immich app —
|
|
// returned here and nowhere else, never logged, never stored
|
|
// 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;
|
|
// The platform's account list, pushed because this plugin's manifest declares `userProvisioning`.
|
|
// Arrives on registration and again on every account create or delete. No reply — it is a statement,
|
|
// not a question.
|
|
case 'users:list':
|
|
setProvisionableUsers(cmd.users);
|
|
// And act on it. Detached deliberately: this reaches out to Immich and can take seconds, while the
|
|
// command handler is on the socket the platform is waiting on. Nothing replies to `users:list`, so
|
|
// there is no answer to delay — but blocking here would stall every later message on this socket.
|
|
void provisionForAccountChange();
|
|
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'));
|