photos, extracted into plugins/photos — the platform half
Fifth extraction, and the clean case: no reverse dependency, configuration already server-side, frontend already panels. Everything photos now lives at gitea.officer.dev/plugins/photos. Deleted, not commented: the sidecar, the api router, the db directory, the Photos app (25 files), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, both database barrels, the hono.ts import comment, and the app-store catalogue entry. The catalogue deletion is required rather than tidying — the trap the runbook records as having bitten twice. permissionAvailability() derives from sidecar_installs, which a plugin never gets a row in, so a leftover entry makes the permission permanently 'unavailable' and withholds the route and dock tile on a server where the plugin is installed and working. ── Test fixtures, repointed rather than deleted ── Photos was the canonical app-store fixture across four test files, chosen for being existing|provisioned with members: 'accounts'. Jellyfin has the identical shape, so the assertions moved there — they are about the installer, not about photos, and deleting them would have quietly dropped coverage of the provisioned install path along with the plugin. One loss worth naming: Immich's `provisioned` install mode went with the catalogue entry. Docker provisioning for plugins does not exist yet, so photos is point-at-your-own-instance until it does. bunx tsgo clean. 1080 pass / 0 fail, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// /api/photos/* — auth, then forward to officer-photos. No routes of its own and no Immich knowledge:
|
||||
// this file must never grow app logic.
|
||||
//
|
||||
// The sidecar owns the Immich contract and holds its API key.
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'photos',
|
||||
prefix: '/api/photos',
|
||||
// Originals and `download/archive` zips are large and Immich builds the archive as it streams it, so the
|
||||
// socket can sit quiet longer than the default 60s idle drop allows.
|
||||
timeoutSeconds: 600,
|
||||
// Immich judges an asset from its first few KB and rejects immediately, while we are still writing —
|
||||
// and the response is lost in the teardown, which is what made every large upload fail with an
|
||||
// unexplained empty 400. Buffering here removes the overlap. The sidecar does the same on its own hop
|
||||
// to Immich (sidecar/photos/routes.ts → readBodyCapped), and both are needed: fixing one alone still
|
||||
// lost the answer roughly two runs in three at 32 MB.
|
||||
bufferRequestBody: true,
|
||||
});
|
||||
|
||||
export const photosRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getPhotosServerUrl = proxy.getHttpUrl;
|
||||
@@ -94,7 +94,7 @@ describe('permissions it claims to back', () => {
|
||||
|
||||
describe('byId', () => {
|
||||
it('finds a known entry and returns undefined for anything else', () => {
|
||||
expect(byId('photos')?.process).toBe('officer-photos');
|
||||
expect(byId('jellyfin')?.process).toBe('officer-jellyfin');
|
||||
expect(byId('not-a-sidecar')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,27 +163,12 @@ export type CatalogueEntry = {
|
||||
|
||||
export const CATALOGUE: CatalogueEntry[] = [
|
||||
// ── Point at something you already run, or let us start one ────────────────────────────────────────
|
||||
{
|
||||
id: 'photos',
|
||||
ui: { name: 'Photos', icon: 'Images', color: '#10b981', rootRoute: '/photos', routes: ['/photos'] },
|
||||
process: 'officer-photos',
|
||||
label: 'Photos',
|
||||
summary: 'Your Immich library — browse, search, upload from the phone',
|
||||
members: 'accounts',
|
||||
modes: ['existing', 'provisioned'],
|
||||
permission: 'photos',
|
||||
composeTemplate: 'immich',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' },
|
||||
{
|
||||
key: 'secret',
|
||||
label: 'API key',
|
||||
type: 'secret',
|
||||
required: true,
|
||||
help: 'Immich → Account Settings → API Keys. Create it with all permissions: a scoped key returns 403 per route, which reads as a broken feature.',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Photos was here until 2026-08-18, when it became `plugins/photos/`.
|
||||
//
|
||||
// Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having
|
||||
// bitten twice. `permissionAvailability()` derives from `sidecar_installs`, which a plugin never gets a
|
||||
// row in, so a leftover entry would make the permission permanently `unavailable`, put `/photos` into
|
||||
// `deniedRoutes`, and withhold the dock tile — on a server where the plugin is installed and working.
|
||||
{
|
||||
id: 'jellyfin',
|
||||
ui: { name: 'Video', icon: 'Clapperboard', color: '#a855f7', rootRoute: '/jellyfin', routes: ['/jellyfin'] },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { byId } from './catalogue';
|
||||
// the reason the effects are injected. What is being pinned here is the behaviour that only shows up
|
||||
// when something goes wrong: resume, blocking, and not doing work twice.
|
||||
|
||||
const photos = byId('photos')!; // existing|provisioned, members: 'accounts'
|
||||
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
|
||||
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
|
||||
const configOnly = byId('notify')!; // config only
|
||||
|
||||
@@ -39,15 +39,15 @@ describe('planSteps', () => {
|
||||
it('never provisions when pointing at an instance the user already runs', () => {
|
||||
// The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather
|
||||
// than remembered at each call site.
|
||||
expect(planSteps(photos, 'existing')).not.toContain('provision');
|
||||
expect(planSteps(photos, 'provisioned')).toContain('provision');
|
||||
expect(planSteps(jellyfin, 'existing')).not.toContain('provision');
|
||||
expect(planSteps(jellyfin, 'provisioned')).toContain('provision');
|
||||
});
|
||||
|
||||
it('omits the members step for a service with no user concept', () => {
|
||||
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
|
||||
// silent failure to anyone debugging why a member has no access.
|
||||
expect(planSteps(transmission, 'provisioned')).not.toContain('members');
|
||||
expect(planSteps(photos, 'provisioned')).toContain('members');
|
||||
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
|
||||
});
|
||||
|
||||
it('has nothing to connect for a config-only install', () => {
|
||||
@@ -57,7 +57,7 @@ describe('planSteps', () => {
|
||||
it('always starts with preflight', () => {
|
||||
// Checking the host before anything is written is the whole reason a half-install is avoidable.
|
||||
for (const mode of ['existing', 'provisioned', 'config'] as const) {
|
||||
expect(planSteps(photos, mode)[0]).toBe('preflight');
|
||||
expect(planSteps(jellyfin, mode)[0]).toBe('preflight');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe('planSteps', () => {
|
||||
describe('a clean run', () => {
|
||||
it('executes the plan in order and reports installed', async () => {
|
||||
const { effects, calls } = spyEffects();
|
||||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||||
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
|
||||
|
||||
expect(out.status).toBe('installed');
|
||||
expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']);
|
||||
@@ -82,7 +82,7 @@ describe('a clean run', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects });
|
||||
await runInstall({ entry: jellyfin, mode: 'provisioned', values: { given: 'yes' }, effects });
|
||||
|
||||
expect(seen.url).toBe('http://127.0.0.1:18091');
|
||||
expect(seen.composeDir).toBe('/root/dockers/x');
|
||||
@@ -92,7 +92,7 @@ describe('a clean run', () => {
|
||||
it('does not mutate the caller’s values', async () => {
|
||||
const values = { given: 'yes' };
|
||||
const { effects } = spyEffects();
|
||||
await runInstall({ entry: photos, mode: 'provisioned', values, effects });
|
||||
await runInstall({ entry: jellyfin, mode: 'provisioned', values, effects });
|
||||
expect(values).toEqual({ given: 'yes' });
|
||||
});
|
||||
});
|
||||
@@ -103,7 +103,7 @@ describe('resuming', () => {
|
||||
const { effects, calls } = spyEffects();
|
||||
const done: StepName[] = ['preflight', 'provision', 'connect'];
|
||||
|
||||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects });
|
||||
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, completed: done, effects });
|
||||
|
||||
expect(out.status).toBe('installed');
|
||||
expect(calls).toEqual(['schema', 'assets', 'process', 'members']);
|
||||
@@ -113,7 +113,7 @@ describe('resuming', () => {
|
||||
it('reports every completed step, including the ones it skipped', async () => {
|
||||
const { effects } = spyEffects();
|
||||
const out = await runInstall({
|
||||
entry: photos,
|
||||
entry: jellyfin,
|
||||
mode: 'provisioned',
|
||||
values: {},
|
||||
completed: ['preflight'],
|
||||
@@ -131,7 +131,7 @@ describe('blocking on a human', () => {
|
||||
connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }),
|
||||
});
|
||||
|
||||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||||
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
|
||||
|
||||
expect(out.status).toBe('blocked');
|
||||
if (out.status !== 'blocked') throw new Error('unreachable');
|
||||
@@ -145,7 +145,7 @@ describe('blocking on a human', () => {
|
||||
it('re-runs the blocking step on resume, once the human has answered', async () => {
|
||||
const { effects, calls } = spyEffects();
|
||||
const out = await runInstall({
|
||||
entry: photos,
|
||||
entry: jellyfin,
|
||||
mode: 'provisioned',
|
||||
values: { secret: 'now-provided' },
|
||||
completed: ['preflight', 'provision'],
|
||||
@@ -165,7 +165,7 @@ describe('failing', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||||
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
|
||||
|
||||
expect(out.status).toBe('failed');
|
||||
if (out.status !== 'failed') throw new Error('unreachable');
|
||||
@@ -184,7 +184,7 @@ describe('failing', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||||
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
|
||||
|
||||
expect(out.status).toBe('failed');
|
||||
if (out.status !== 'failed') throw new Error('unreachable');
|
||||
@@ -203,7 +203,7 @@ describe('failing', () => {
|
||||
throw new Error('pm2 not found');
|
||||
},
|
||||
});
|
||||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||||
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
|
||||
expect(out.status).toBe('failed');
|
||||
if (out.status !== 'failed') throw new Error('unreachable');
|
||||
expect(out.at).toBe('process');
|
||||
|
||||
@@ -10,7 +10,7 @@ const inviteOnly = { members: 'invite' } as CatalogueEntry;
|
||||
|
||||
describe('what adding a member would do', () => {
|
||||
it('predicts the outcome per service without contacting anything', () => {
|
||||
expect(plannedOutcome(byId('photos')!)).toBe('provisioned');
|
||||
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
|
||||
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
||||
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
|
||||
});
|
||||
@@ -18,8 +18,8 @@ describe('what adding a member would do', () => {
|
||||
|
||||
describe('which installed services have work to do', () => {
|
||||
it('skips single-tenant daemons entirely', () => {
|
||||
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'memos']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['memos', 'photos']);
|
||||
const ids = servicesNeedingMemberProvision(['jellyfin', 'transmission', 'slskd', 'memos']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
|
||||
});
|
||||
|
||||
it('ignores services that are not installed', () => {
|
||||
|
||||
@@ -12,8 +12,8 @@ describe('preflight is per mode, not per entry', () => {
|
||||
it('allows pointing at an existing instance without Docker', async () => {
|
||||
// The whole point: a host with no Docker can still use Photos against an Immich elsewhere.
|
||||
// Refusing the entry outright is the over-strict check that makes people bypass the installer.
|
||||
const photos = byId('photos')!;
|
||||
expect(await preflight(photos, 'existing')).toEqual({ ok: true });
|
||||
const jellyfin = byId('jellyfin')!;
|
||||
expect(await preflight(jellyfin, 'existing')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('allows a config-only sidecar regardless', async () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
// import { transmissionRouter } from './api/transmission/router';
|
||||
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||
// import { jellyfinRouter } from './api/jellyfin/router';
|
||||
// import { photosRouter } from './api/photos/router';
|
||||
// import { walletRouter } from './api/wallet/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
// import { caldavRouter } from './api/dav/sidecar-server';
|
||||
|
||||
@@ -161,15 +161,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// commenting on their own issues — security theatre with a real cost and no benefit.
|
||||
personal: ['/'],
|
||||
},
|
||||
{
|
||||
key: 'photos',
|
||||
label: 'Photos',
|
||||
description: 'Browse the photo library',
|
||||
kind: 'app',
|
||||
// api: ['/photos'], // plugin — switched off 2026-08-13
|
||||
api: [],
|
||||
routes: ['/photos'],
|
||||
},
|
||||
{
|
||||
key: 'jellyfin',
|
||||
label: 'Video',
|
||||
|
||||
@@ -109,6 +109,26 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// Email ships all four: the proxy router, `email_accounts`, the sync sidecar, and the mail panels.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'photos',
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Photos',
|
||||
summary: 'Your Immich library — browse, search, upload from the phone',
|
||||
color: '#10b981',
|
||||
icon: 'Images',
|
||||
source: 'https://gitea.officer.dev/plugins/photos.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'photos',
|
||||
label: 'Photos',
|
||||
description: 'Browse the photo library',
|
||||
},
|
||||
],
|
||||
// No osDependencies: the sidecar is a Bun HTTP proxy and reaches Immich with `fetch`.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'vault',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import type { UpstreamTarget } from './upstream';
|
||||
import {
|
||||
createPhotosAccount,
|
||||
deletePhotosAccount,
|
||||
getPhotosCredentials,
|
||||
listPhotosAccounts,
|
||||
recordPhotosProbe,
|
||||
setActivePhotosAccount,
|
||||
updatePhotosAccount,
|
||||
} from 'officerdb';
|
||||
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);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../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'));
|
||||
@@ -1,332 +0,0 @@
|
||||
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();
|
||||
@@ -1,207 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// 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 'officerdb';
|
||||
|
||||
/** 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