add photos, an immich-backed library behind its own sidecar
officer-photos owns the whole Immich contract: the instance URL and the API key live there and nowhere else, and the platform side is an auth-gated forwarder holding no credentials. The route surface is an allow-list keyed on the first path segment, so admin, auth, api-keys, sessions, jobs, system-config and libraries are unreachable by construction rather than by enumeration. The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums, people, favorites, sharing, archive, trash — because the point of a sidecar screen is to reproduce what the upstream already ships, then extend it. The timeline reads Immich's columnar time-bucket format directly; selection lives in the URL per docs/navigation-audit.md. Two things worth knowing for anyone touching this later: - `duration` is an integer count of milliseconds in Immich 3.0. It was an HH:MM:SS.mmm string before, and every stale example still shows that form. - the map container is sized with h-full/w-full, never `absolute inset-0`. maplibre's stylesheet sets `position: relative; overflow: hidden` on the element it is given, and an unlayered vendor rule beats Tailwind 4's layered `.absolute` regardless of source order — so the div collapses to height 0 and clips its own canvas away. Nothing errors: the GL context is healthy, tiles download and pixels are drawn into a buffer nobody ever composites. maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file from import.meta.url, which Officer's index.html fallback answers with HTML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { callUpstream, getBase, getConfig } from './upstream';
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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 and reports the Immich version and who the key is.
|
||||
// * /_officer/<path> forwarded to <IMMICH_URL>/api/<path>, first-segment allow-list (routes.ts)
|
||||
// anything else 404
|
||||
//
|
||||
// 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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** 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 cfg = getConfig();
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
if (!cfg) return Response.json({ ok: false, error: 'IMMICH_URL/IMMICH_API_KEY not configured' }, { status: 503 });
|
||||
const started = Date.now();
|
||||
try {
|
||||
// Version is public, so it separates "instance down" from "key rejected" in one shot.
|
||||
const [versionRes, meRes] = await Promise.all([
|
||||
callUpstream(cfg, { path: '/api/server/version', withKey: false }),
|
||||
callUpstream(cfg, { path: '/api/users/me' }),
|
||||
]);
|
||||
if (!versionRes.ok) {
|
||||
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
|
||||
}
|
||||
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
|
||||
const version = [v.major, v.minor, v.patch].every((n) => typeof n === 'number')
|
||||
? `${v.major}.${v.minor}.${v.patch}`
|
||||
: null;
|
||||
if (!meRes.ok) {
|
||||
return Response.json(
|
||||
{ ok: false, version, error: `IMMICH_API_KEY rejected (${meRes.status})`, ms: Date.now() - started },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const me = (await meRes.json()) as { email?: string; name?: string };
|
||||
return Response.json({ ok: true, version, user: me.email ?? me.name ?? null, ms: Date.now() - started });
|
||||
} catch (err) {
|
||||
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
if (!cfg) return Response.json({ error: 'photos not configured' }, { 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 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[photos] listening on 127.0.0.1:${port} -> ${getBase() ?? '(IMMICH_URL unset)'}`);
|
||||
|
||||
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',
|
||||
capabilities: ['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,108 @@
|
||||
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/*
|
||||
// (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;
|
||||
|
||||
/**
|
||||
* Forward one request to Immich and stream the answer back.
|
||||
*
|
||||
* The body is 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.
|
||||
*/
|
||||
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();
|
||||
|
||||
const res = await callUpstream(cfg, {
|
||||
path: `/api/${rest}`,
|
||||
method: req.method,
|
||||
query: search ? `?${search}` : '',
|
||||
body: hasBody ? await req.arrayBuffer() : null,
|
||||
contentType: req.headers.get('content-type'),
|
||||
range: req.headers.get('range'),
|
||||
ifNoneMatch: req.headers.get('if-none-match'),
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
for (const name of PASSTHROUGH_HEADERS) {
|
||||
const value = res.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
|
||||
// 204 and 304 must not carry a body, and Bun throws if one is attached.
|
||||
const body = res.status === 204 || res.status === 304 ? null : res.body;
|
||||
return new Response(body, { status: res.status, headers });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Immich upstream config for the officer-photos sidecar.
|
||||
//
|
||||
// All knowledge of the Immich instance — its URL and its API key — lives here, mirroring
|
||||
// officer-invoiceshelf/officer-transmission/officer-slskd: the platform API is a thin auth+forward proxy
|
||||
// and holds NO Immich credentials.
|
||||
//
|
||||
// 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.
|
||||
|
||||
const { IMMICH_URL, IMMICH_API_KEY } = process.env;
|
||||
|
||||
export type UpstreamConfig = { base: string; key: string };
|
||||
|
||||
let warnedUnset = false;
|
||||
|
||||
/** The instance URL alone, for logging — set without a key is a real state and should read as one. */
|
||||
export function getBase(): string | null {
|
||||
return IMMICH_URL?.trim().replace(/\/+$/, '') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
|
||||
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
|
||||
*/
|
||||
export function getConfig(): UpstreamConfig | null {
|
||||
const base = IMMICH_URL?.trim().replace(/\/+$/, '');
|
||||
const key = IMMICH_API_KEY?.trim();
|
||||
if (!base || !key) {
|
||||
if (!warnedUnset) {
|
||||
const missing = [!base && 'IMMICH_URL', !key && 'IMMICH_API_KEY'].filter(Boolean).join(' and ');
|
||||
console.warn(`[photos] ${missing} unset — the sidecar will respond 503 until set`);
|
||||
warnedUnset = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { base, key };
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
/** The single door to Immich. Everything the sidecar fetches goes through here. */
|
||||
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
|
||||
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;
|
||||
|
||||
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body ?? undefined,
|
||||
// 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',
|
||||
});
|
||||
|
||||
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