The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:
• No reverse dependency. Nothing in src/ imported anything of photos'.
• Configuration was already right. The URL and API key live in photos_config,
a per-user registry encrypted at rest and driven from the app. Vault had to
move VAULTWARDEN_URL out of .env and invent a settings panel; here the
sidecar already read the database, and its own comment already said why
nothing reads process.env.
• The frontend was already panels. The screen was a WorkspaceView around
exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
the screen's contents with the wrapper removed.
The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.
Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.
Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
9.9 KiB
TypeScript
208 lines
9.9 KiB
TypeScript
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);
|
|
}
|