Photos had no user provisioning at all: one registry row, the owner's, and every other account resolving to nothing. Same shape as the Jellyfin gap, but this one was never built rather than built and dropped. photos_accounts records who on Officer is who on Immich, and carries that person's own API key. getConfig falls back to it when the caller has no registry of their own, and there is no path from there to the owner's key — an Immich key with permissions:['all'] can read and delete the whole library, so a member resolving to it would be administering everyone's photographs rather than reading their own. Matching is on email, which Immich enforces uniqueness on and Officer already holds. That is exact where the Jellyfin side has to go through an uppercased username index and cope with two Officer names colliding into one. Two deliberate differences from the Jellyfin flow: The admin's own account is reported as itself, not as "no Officer account". That bug shipped there and was fixed in 591c0da; there was no reason to write it twice, so reconcile takes the key's identity from /api/users/me and the test pins it. And the temporary password is HANDED OVER rather than discarded. Jellyfin discards because it has no must-change-password flag, so anything handed over stays valid forever. Immich has one, and Immich's own app is where phone backup runs — discarding would leave a photo account that can never back a phone up again. So: set a password, spend it on a key, raise shouldChangePassword, return it once. POST /_config/:id/handover re-issues on demand, because "setting up my phone" happens long after provisioning and more than once. Verified against the running 3.1.0 image that an API key survives a password change, so re-issuing never costs Officer its access. Applied on alpha: conceiba and fedra linked, guipinto and diogo created, all four keyed and each resolving to their own Immich identity. jrg.conceicao left alone. Members still need the photos grant on the Member role before the permission gate lets them through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
194 lines
8.9 KiB
TypeScript
194 lines
8.9 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 { 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);
|
|
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'));
|