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>
This commit is contained in:
@@ -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<ProbeResult> {
|
||||
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<Response> {
|
||||
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<void> {
|
||||
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<AccountBody> =>
|
||||
((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<string> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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 '/<id>' / '/<id>/activate'. */
|
||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
@@ -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/<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'));
|
||||
@@ -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<number, Elevation>();
|
||||
|
||||
/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */
|
||||
async function drop(userId: number): Promise<void> {
|
||||
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<Body> => ((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<string> {
|
||||
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<string> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<string, string>) =>
|
||||
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<string, string> | 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<string, readonly string[]> = {
|
||||
assets: ['GET', 'PUT'],
|
||||
search: ['POST'],
|
||||
timeline: ['GET'],
|
||||
};
|
||||
|
||||
/** `/_locked/api/<path>` — the same shape as `/_officer/<path>`, but on the session and far narrower. */
|
||||
async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise<Response> {
|
||||
// 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 <img> 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/<id>/thumbnail'. */
|
||||
export async function handleLockedRoute(
|
||||
req: Request,
|
||||
cfg: UpstreamConfig,
|
||||
userId: number,
|
||||
subpath: string,
|
||||
): Promise<Response> {
|
||||
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();
|
||||
@@ -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<string, readonly string[]> = {
|
||||
// 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<ArrayBuffer> } | { 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<Response | null> {
|
||||
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 <img>/<video> cannot send an Authorization header, so the browser puts Officer's JWT in `?token=`
|
||||
// (userMiddleware accepts it there). That token is Officer's, not Immich's: forwarding it would write the
|
||||
// owner's session credential into Immich's access log for every thumbnail. Drop it at the boundary.
|
||||
const query = new URLSearchParams(url.search);
|
||||
query.delete('token');
|
||||
const search = query.toString();
|
||||
|
||||
// Buffered, not streamed — see readBodyCapped. A body that is still being written when Immich answers
|
||||
// loses the answer, which is what made every large upload fail with an unexplained empty 400.
|
||||
let body: BodyInit | null = null;
|
||||
if (hasBody && req.body) {
|
||||
const read = await readBodyCapped(req);
|
||||
// Over the cap (or no declared length): stream it, exactly as this always did. Not an error.
|
||||
body = read.ok ? read.bytes : req.body;
|
||||
}
|
||||
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `/api/${rest}`,
|
||||
method: req.method,
|
||||
query: search ? `?${search}` : '',
|
||||
body,
|
||||
contentType: req.headers.get('content-type'),
|
||||
range: req.headers.get('range'),
|
||||
ifNoneMatch: req.headers.get('if-none-match'),
|
||||
});
|
||||
|
||||
return relayResponse(res);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Immich upstream config for the officer-photos sidecar.
|
||||
//
|
||||
// All knowledge of the Immich instance — its URL and its API key — lives here. The platform API is a thin
|
||||
// auth+forward proxy and holds NO Immich credentials.
|
||||
//
|
||||
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `photos_config` (see
|
||||
// databases/officer_db/src/photos/queries.ts). It is deliberately no longer read from the environment:
|
||||
// Bun auto-loads `.env` into every process started in the platform directory, so an `IMMICH_API_KEY` there
|
||||
// was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no code
|
||||
// to use it and the largest attack surface in the system. Nothing in this file reads process.env.
|
||||
//
|
||||
// Two things about Immich's API are load-bearing:
|
||||
//
|
||||
// 1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a
|
||||
// 403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad
|
||||
// credential. Create the key with all permissions unless there is a reason not to.
|
||||
// 2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative
|
||||
// credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is
|
||||
// exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
|
||||
// nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.
|
||||
|
||||
import { getActivePhotosCredentials } from '../db/queries';
|
||||
|
||||
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
||||
export type UpstreamTarget = { base: string; key: string };
|
||||
|
||||
/**
|
||||
* A stored account, which is where every real call goes.
|
||||
*
|
||||
* `id` and `label` ride along because the owner can have several accounts registered and only one selected:
|
||||
* a probe has to be recorded against the row it actually reached, and a log line saying which library
|
||||
* answered is the difference between "photos is broken" and "you are looking at the other account".
|
||||
*/
|
||||
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
|
||||
|
||||
/** Trailing slashes off, so `${base}/api/...` never doubles the separator. */
|
||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
// A thumbnail grid is a hundred requests in a second and each one needs the key, so the row is cached rather
|
||||
// than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in
|
||||
// psql, which then takes effect within a minute instead of needing a restart.
|
||||
const TTL_MS = 60_000;
|
||||
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||
|
||||
/**
|
||||
* The owner's SELECTED account, or null when /photos has no account yet — the sidecar then answers 503,
|
||||
* and the UI turns that into the setup form rather than a wall of empty grids.
|
||||
*/
|
||||
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
||||
const hit = cache.get(userId);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||
|
||||
const creds = await getActivePhotosCredentials(userId);
|
||||
const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : null;
|
||||
cache.set(userId, { cfg, at: Date.now() });
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
|
||||
export function invalidateConfig(userId: number): void {
|
||||
cache.delete(userId);
|
||||
}
|
||||
|
||||
/** Carries the upstream status so callers can tell a rejected credential from an unreachable instance. */
|
||||
export class UpstreamError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Immich errors as `{ message }`; fall back to the status when the body is not that (a proxy's HTML, say). */
|
||||
async function upstreamMessage(res: Response, fallback: string): Promise<string> {
|
||||
const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
|
||||
const message = typeof body?.message === 'string' ? body.message : '';
|
||||
return message ? `${message} (${res.status})` : `${fallback} with ${res.status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade an email and password for a SESSION token.
|
||||
*
|
||||
* A session is not an API key and the difference is load-bearing in both directions: sessions are listed as
|
||||
* devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather than the
|
||||
* `x-api-key` header everything else here uses — but they are also the ONLY credential Immich will elevate.
|
||||
* `POST /auth/session/unlock` and `POST /sessions` both open with `if (!auth.session) throw` (verified against
|
||||
* the running 3.1.0 image), so an API key can never reach the locked folder and can never mint a session to
|
||||
* get there. That is why this is exported rather than inlined into mintApiKey.
|
||||
*
|
||||
* The password is used here and nowhere else. Callers hold the returned token or discard it; nothing stores it.
|
||||
*/
|
||||
export async function login(base: string, email: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
redirect: 'manual',
|
||||
});
|
||||
// Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus
|
||||
// "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them.
|
||||
if (!res.ok) throw new UpstreamError(await upstreamMessage(res, 'sign-in failed'), res.status);
|
||||
|
||||
const session = (await res.json()) as { accessToken?: unknown };
|
||||
if (typeof session.accessToken !== 'string' || !session.accessToken) {
|
||||
throw new UpstreamError('sign-in returned no access token', res.status);
|
||||
}
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a session. Best effort by design: a session we cannot close is untidy, not a failure of whatever the
|
||||
* caller was actually doing. Skipping it entirely would leave a phantom "device" in the owner's Immich
|
||||
* account for every sign-in Officer makes.
|
||||
*/
|
||||
export async function logout(base: string, token: string): Promise<void> {
|
||||
await fetch(`${base}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'manual',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own
|
||||
* settings screen.
|
||||
*
|
||||
* `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only
|
||||
* grantable because a session has every permission — an API key can never mint a key stronger than itself
|
||||
* (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys are scoped,
|
||||
* and an under-scoped one returns 403 per route, which reads as a broken feature.
|
||||
*
|
||||
* The session exists only to hold the key and is closed in the `finally`. The password is used here and
|
||||
* nowhere else; only the key survives this function.
|
||||
*/
|
||||
export async function mintApiKey(base: string, email: string, password: string): Promise<string> {
|
||||
const accessToken = await login(base, email, password);
|
||||
const bearer = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
try {
|
||||
const created = await fetch(`${base}/api/api-keys`, {
|
||||
method: 'POST',
|
||||
headers: { ...bearer, Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Officer', permissions: ['all'] }),
|
||||
redirect: 'manual',
|
||||
});
|
||||
if (!created.ok) {
|
||||
throw new UpstreamError(await upstreamMessage(created, 'could not create an API key'), created.status);
|
||||
}
|
||||
|
||||
const payload = (await created.json()) as { secret?: unknown };
|
||||
if (typeof payload.secret !== 'string' || !payload.secret) {
|
||||
throw new UpstreamError('Immich created a key but returned no secret', created.status);
|
||||
}
|
||||
return payload.secret;
|
||||
} finally {
|
||||
await logout(base, accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
type CallOptions = {
|
||||
/** Absolute path on the Immich host, e.g. `/api/albums`. */
|
||||
path: string;
|
||||
method?: string;
|
||||
/** Raw search string including the leading `?`, or empty. */
|
||||
query?: string;
|
||||
/**
|
||||
* A string for the JSON callers, or the forwarder's already-buffered request body.
|
||||
*
|
||||
* This used to be a stream, for the obvious reason that a phone's video should not sit in this
|
||||
* process's heap. It was changed back deliberately: a request body still being written when Immich
|
||||
* answers loses the answer entirely — Node replaces the app's response with a bodyless 400 and closes
|
||||
* — which is what made every large upload fail inexplicably. `readBodyCapped` in routes.ts has the
|
||||
* measurements and the alternatives that were tried; the heap cost is the accepted price.
|
||||
*
|
||||
* A buffer also carries a Content-Length, so the request to Immich is no longer chunked.
|
||||
*/
|
||||
body?: BodyInit | null;
|
||||
contentType?: string | null;
|
||||
/** Byte range for thumbnail/original/video reads, forwarded verbatim. */
|
||||
range?: string | null;
|
||||
ifNoneMatch?: string | null;
|
||||
/** Set false for the version/ping routes, which Immich serves unauthenticated. */
|
||||
withKey?: boolean;
|
||||
/**
|
||||
* A session token to authenticate as, INSTEAD of the stored API key.
|
||||
*
|
||||
* Only the locked-folder surface sets this (see locked.ts). Elevation lives on a session, so a request that
|
||||
* must see `visibility: 'locked'` assets has to carry one; everything else stays on the key. The two are
|
||||
* mutually exclusive on purpose — sending both would leave which credential Immich actually used up to its
|
||||
* header precedence rather than to this file.
|
||||
*/
|
||||
bearer?: string;
|
||||
};
|
||||
|
||||
/** The single door to Immich. Everything the sidecar fetches goes through here. */
|
||||
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
|
||||
if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`;
|
||||
else if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
|
||||
if (opts.contentType) headers['Content-Type'] = opts.contentType;
|
||||
if (opts.range) headers.Range = opts.range;
|
||||
if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;
|
||||
|
||||
// `duplex: 'half'` is required by the fetch spec whenever the body is a stream, and is absent from
|
||||
// TypeScript's RequestInit — hence the widened type, same as createSidecarProxy does. Harmless for the
|
||||
// string bodies.
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body ?? undefined,
|
||||
duplex: 'half',
|
||||
// A redirect from an API route means something has gone wrong with auth; surface it rather than
|
||||
// following it into an HTML page.
|
||||
redirect: 'manual',
|
||||
};
|
||||
|
||||
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, init);
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
// 403 is the interesting one: the key is valid but lacks the permission this route needs.
|
||||
console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (key rejected or under-scoped)`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
Reference in New Issue
Block a user