From d2e2ebe10843129b05470e1817a674433c416c56 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 10 Oct 2026 15:52:42 +0700 Subject: [PATCH] feat(immich): read Immich through a per-user read-only proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser cannot talk to Immich directly: the key must stay out of it, COEP blocks the origin, and the app has no place to keep a key per user. So the backend keeps it. `src/immich.ts` holds the whole surface — the user's servers live in a JSON column on `users` (additive migration), and every route reads the key from there and never takes a URL from the browser except when probing one. Albums, a page of assets, a thumbnail and an original, all behind the normal session check. `probe` is the only route that touches a URL the client named, and it validates it first (http/https only, no credentials, no path, no query, no hash) so the browser cannot turn the backend into a proxy to an arbitrary host. The key is masked down to its last four characters everywhere it comes back out, and no log line carries it. The share-link path is the same routes with `type: 'share'`, whose key travels as `?key=`, so there is one code path per call rather than two. test/immich.mjs runs a fake Immich on loopback — two keys with different albums, one of them without `asset.download` — and checks 59 things including that neither the responses nor the log leak a key. --- docker/.env.example | 7 + docker/backend/package.json | 3 +- docker/backend/src/db.ts | 50 ++++ docker/backend/src/immich.ts | 471 +++++++++++++++++++++++++++++++++ docker/backend/src/server.ts | 10 + docker/backend/test/immich.mjs | 376 ++++++++++++++++++++++++++ docker/docker-compose.yml | 5 + 7 files changed, 921 insertions(+), 1 deletion(-) create mode 100644 docker/backend/src/immich.ts create mode 100644 docker/backend/test/immich.mjs diff --git a/docker/.env.example b/docker/.env.example index d40b736..ecac8d1 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -23,3 +23,10 @@ SMTP_PASS= # What the mail says it is from. Defaults to SMTP_USER, then a noreply address. SMTP_FROM= SMTP_SECURE= + +# Immich, as a second photo source beside the folders on disk in LIBRARY. This is +# only the address the "add an Immich server" dialog starts with — the key is +# each account's own, typed in the app and kept in the database (never in the +# browser, never in this file). Leave it empty and the field starts blank; the +# "add Immich" button is there either way. +IMMICH_URL=https://photos.labz.io.vn diff --git a/docker/backend/package.json b/docker/backend/package.json index 973a2f7..c4496ec 100644 --- a/docker/backend/package.json +++ b/docker/backend/package.json @@ -8,7 +8,8 @@ "build": "tsc", "start": "node dist/server.js", "dev": "tsx watch src/server.ts", - "test": "node test/security.mjs" + "test": "node test/security.mjs", + "test:immich": "node test/immich.mjs" }, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 31c5134..0cc0f95 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -183,6 +183,18 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string => } } +// The Immich servers an account added to its LIBRARY — one JSON array per user, +// each entry holding the address, the key its owner pasted and the albums they +// ticked (see src/immich.ts). A column rather than a table because the list is +// only ever read whole and written whole: there is nothing to query across +// accounts, and a user's row is already the unit that is deleted together. +{ + const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'immich')) { + db.exec(`ALTER TABLE users ADD COLUMN immich TEXT`); + } +} + // The mailed code, added once visitors were expected to prove an address // without leaving the page. Rows minted before it keep working as links: their // `code` is NULL, which no typed guess can match (see verifyEmailCode). @@ -624,6 +636,44 @@ export function listUsersWithCounts(): AdminUser[] { .all() as AdminUser[]; } +// ---- Immich servers, per account --------------------------------------------- +// What src/immich.ts stores for one server: the address, the key its owner +// pasted, and the crossing choices the dialog made. `version` and `canDownload` +// are the answers of the last probe, kept so the LIBRARY tree can draw without +// asking Immich anything. +export type ImmichServer = { + id: string; + name: string; + url: string; + key: string; + type: 'api' | 'share'; + version: string | null; + canDownload: boolean | null; + albums: string[]; +}; + +// The key is in clear in this column, like the password hash beside it: the file +// is the deployment's own disk. What matters is that it never travels back out — +// the routes send a masked copy and nothing else (see src/immich.ts). +export function immichServers(userId: number): ImmichServer[] { + const row = db.prepare('SELECT immich FROM users WHERE id = ?').get(userId) as + | { immich: string | null } + | undefined; + if (!row?.immich) return []; + try { + const parsed: unknown = JSON.parse(row.immich); + return Array.isArray(parsed) ? (parsed as ImmichServer[]) : []; + } catch { + // A column hand-edited into nonsense reads as "no server added", which is + // the same thing the user sees and can fix from the dialog. + return []; + } +} + +export function setImmichServers(userId: number, servers: ImmichServer[]): void { + db.prepare('UPDATE users SET immich = ? WHERE id = ?').run(JSON.stringify(servers), userId); +} + // ---- moderation ------------------------------------------------------------- // Blocking and removing both drop the account's live sessions: the state has to // take effect on the next request, not whenever the cookie happens to expire. diff --git a/docker/backend/src/immich.ts b/docker/backend/src/immich.ts new file mode 100644 index 0000000..5bac647 --- /dev/null +++ b/docker/backend/src/immich.ts @@ -0,0 +1,471 @@ +// Immich, as a second photo source for the LIBRARY module — server side only. +// +// Two constraints decide this file's whole shape (see md/immich-library-plan.md): +// the SPA is served with COEP `require-corp`, so nothing cross-origin can be +// fetched from the page; and an Immich key reads a whole family library, so it +// must never reach the browser. One move answers both — the page talks to these +// routes, these routes talk to Immich, and the key stays in the users table. +// +// Every route here is read-only towards Immich: no upload, no edit, no delete. +// That is what the dialog asks for, and it is why the worst a stolen session can +// do is read what its own key could already read. +import { randomBytes } from 'node:crypto'; +import { Readable } from 'node:stream'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { immichServers, setImmichServers, type ImmichServer, type User } from './db'; + +const MAX_URL = 300; +const MAX_KEY = 400; +const MAX_NAME = 60; +const MAX_ALBUMS = 200; +const MAX_PAGE_SIZE = 200; + +// A server is a box the operator may not control, and a hang there must not +// become a hung LIBRARY. Streaming an original is the one call that legitimately +// takes minutes, so it gets a clock of its own. +const PROBE_MS = 8_000; +const FETCH_MS = 5 * 60_000; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const THUMB_SIZES = new Set(['thumbnail', 'preview', 'fullsize']); + +type Json = Record; +// The three refusals the UI can say something about: the key is wrong (or too +// narrow), the box cannot be reached, or it answered with something unusable. +type Failure = { ok: false; error: 'auth' | 'unreachable' | 'server' | 'invalid' }; + +type RawAsset = { + id?: string; + type?: string; + originalFileName?: string; + fileCreatedAt?: string; + width?: number; + height?: number; + exifInfo?: { fileSizeInByte?: number }; +}; +type RawAlbum = { + id?: string; + albumName?: string; + assetCount?: number; + albumThumbnailAssetId?: string | null; + shared?: boolean; +}; + +type AlbumRow = { + id: string; + albumName: string; + assetCount: number; + coverId: string | null; + shared: boolean; +}; + +const albumRow = (album: RawAlbum, count?: number): AlbumRow => ({ + id: album.id ?? '', + albumName: album.albumName ?? '', + assetCount: count ?? album.assetCount ?? 0, + coverId: album.albumThumbnailAssetId ?? null, + shared: album.shared === true, +}); + +// What the client needs to build a row: identity, a name, a date, a size. The +// pixels are never here — the tile route fetches those. +const assetRow = (asset: RawAsset) => ({ + id: asset.id ?? '', + name: asset.originalFileName ?? '', + takenAt: asset.fileCreatedAt ?? null, + width: asset.width ?? null, + height: asset.height ?? null, + size: asset.exifInfo?.fileSizeInByte ?? null, +}); + +// ---- validation at the trust boundary -------------------------------------- +function body(req: FastifyRequest): Json { + const parsed = req.body as unknown; + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Json) : {}; +} + +// The address is the one string that could aim this proxy anywhere, so it is +// checked here: http(s) only, no embedded credentials, no path — Immich is +// reached at the root of its own origin. +function cleanUrl(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim().replace(/\/+$/, ''); + if (!trimmed || trimmed.length > MAX_URL) return null; + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + if (url.username || url.password) return null; + if (url.search || url.hash) return null; + return trimmed; +} + +function cleanKey(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const key = raw.trim(); + if (!key || key.length > MAX_KEY || /\s/.test(key)) return null; + return key; +} + +// A name is what the tree shows beside the node; empty means the host, which is +// what the user would have called it anyway. +function cleanName(raw: unknown, url: string): string { + if (typeof raw === 'string') { + const name = raw.trim().slice(0, MAX_NAME); + if (name) return name; + } + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +// Album IDs are pasted by hand (a `…/albums/` URL is the normal way to get +// one), so the shape is checked and the list capped: these values end up in a +// query to Immich, which is exactly where an unchecked string would hurt. +function cleanAlbums(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const out: string[] = []; + for (const item of raw) { + const found = typeof item === 'string' ? item.match(UUID_RE)?.[0]?.toLowerCase() : undefined; + if (!found || out.includes(found)) continue; + out.push(found); + if (out.length >= MAX_ALBUMS) break; + } + return out; +} + +// What the browser may know about a key: which one it is, never what it is. Four +// characters are enough for its owner to recognise it. +const mask = (key: string) => `••••${key.slice(-4)}`; + +// ---- talking to Immich ------------------------------------------------------- +type Call = { + method?: 'GET' | 'POST'; + path: string; + query?: Record; + json?: unknown; + headers?: Record; + timeout?: number; +}; + +// One place builds the upstream request, so the key is attached the same way +// everywhere: an API key rides in `x-api-key`, a share link's key in `?key=`, +// which is where Immich's own shared routes read it from. Neither ever reaches a +// log line we write. +async function call(server: ImmichServer, req: Call): Promise { + const url = new URL(`${server.url}/api${req.path}`); + for (const [name, value] of Object.entries(req.query ?? {})) { + if (value !== undefined) url.searchParams.set(name, String(value)); + } + const headers: Record = { ...req.headers }; + if (server.type === 'api') headers['x-api-key'] = server.key; + else url.searchParams.set('key', server.key); + const init: RequestInit = { method: req.method ?? 'GET', headers, signal: AbortSignal.timeout(req.timeout ?? PROBE_MS) }; + if (req.json !== undefined) { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(req.json); + } + return fetch(url, init); +} + +async function jsonOf(res: Response): Promise { + try { + return (await res.json()) as T; + } catch { + return null; + } +} + +// Immich's own answer, narrowed to the three the UI can act on. +const refusal = (res: Response): Failure => + res.status === 401 || res.status === 403 ? { ok: false, error: 'auth' } : { ok: false, error: 'server' }; + +const unreachable = (): Failure => ({ ok: false, error: 'unreachable' }); + +// The album list, from whichever kind of key this is: a share link is one album +// it has to ask `shared-links/me` for, an API key gets the whole visible list. +async function listAlbums(server: ImmichServer): Promise { + if (server.type === 'share') { + const res = await call(server, { path: '/shared-links/me' }); + if (!res.ok) return refusal(res); + const me = await jsonOf<{ album?: RawAlbum; assets?: RawAsset[] }>(res); + return me?.album ? [albumRow(me.album, me.assets?.length)] : []; + } + const res = await call(server, { path: '/albums' }); + if (!res.ok) return refusal(res); + return ((await jsonOf(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album)); +} + +async function firstAssetId(server: ImmichServer): Promise { + if (server.type === 'share') { + const res = await call(server, { path: '/shared-links/me' }); + if (!res.ok) return null; + return (await jsonOf<{ assets?: RawAsset[] }>(res))?.assets?.[0]?.id ?? null; + } + const res = await call(server, { method: 'POST', path: '/search/metadata', json: { size: 1, page: 1 } }); + if (!res.ok) return null; + const found = await jsonOf<{ assets?: { items?: RawAsset[] } }>(res); + return found?.assets?.items?.[0]?.id ?? null; +} + +// Whether this key may fetch an original — the one permission the studio +// depends on (see the plan's permission table). A one-byte range keeps the check +// from downloading a RAW file, and the stream is dropped rather than read. +async function canDownload(server: ImmichServer, assetId: string): Promise { + const res = await call(server, { path: `/assets/${assetId}/original`, headers: { Range: 'bytes=0-0' } }); + await res.body?.cancel(); + if (res.status === 200 || res.status === 206) return true; + if (res.status === 401 || res.status === 403) return false; + return null; +} + +type ProbeResult = { ok: true; version: string | null; albums: AlbumRow[]; canDownload: boolean | null } | Failure; + +// "Check this key" — the only place that reaches a server the user has not saved +// yet. It asks the fixed Immich paths above and nothing else, so no part of the +// request can steer it past that list. +async function probe(url: string, key: string, type: 'api' | 'share'): Promise { + const server: ImmichServer = { id: 'probe', name: '', url, key, type, version: null, canDownload: null, albums: [] }; + try { + // The version is public, so it answers even for a key that turns out to be + // wrong — and the dialog gets to show what it is talking to. + const versionRes = await call(server, { path: '/server/version' }); + if (!versionRes.ok) return refusal(versionRes); + const v = await jsonOf<{ major?: number; minor?: number; patch?: number }>(versionRes); + const version = v?.major === undefined ? null : `${v.major}.${v.minor ?? 0}.${v.patch ?? 0}`; + + // Reading albums is also the proof the key works: an unusable key is refused + // here, before anything is stored. + const albums = await listAlbums(server); + if (!Array.isArray(albums)) return albums; + + const assetId = await firstAssetId(server); + return { ok: true, version, albums, canDownload: assetId ? await canDownload(server, assetId) : null }; + } catch { + // A wrong host, a closed port, a TLS failure, a timeout — one answer. + return unreachable(); + } +} + +// ---- routes ------------------------------------------------------------------ +export function immichRoutes( + app: FastifyInstance, + member: (req: FastifyRequest, reply: FastifyReply) => User | undefined, +): void { + const view = (server: ImmichServer) => ({ + id: server.id, + name: server.name, + url: server.url, + type: server.type, + version: server.version, + canDownload: server.canDownload, + albums: server.albums, + keyMasked: mask(server.key), + }); + + // A route names its server by the id we minted, never by an address: nothing a + // client sends can aim this proxy at a host that is not in its own list. + const pick = (user: User, reply: FastifyReply, id: unknown): ImmichServer | undefined => { + const servers = immichServers(user.id); + const server = typeof id === 'string' ? servers.find((s) => s.id === id) : undefined; + if (!server) { + void reply.status(404).send({ error: 'unknown server' }); + return undefined; + } + return server; + }; + + // The saved list, plus the address this deployment suggests for the first one. + // No upstream call at all: the tree has to draw on a phone with no signal. + app.get('/api/immich/config', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + return reply.send({ + ok: true, + defaultUrl: process.env.IMMICH_URL ?? '', + servers: immichServers(user.id).map(view), + }); + }); + + // The dialog's "check this key": nothing is stored, so a typo never becomes a + // row, and the answer carries the albums that key can actually see. + app.post('/api/immich/probe', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const b = body(req); + const url = cleanUrl(b.url); + const key = cleanKey(b.key); + if (!url || !key) return reply.status(400).send({ ok: false, error: 'invalid' }); + return reply.send(await probe(url, key, b.type === 'share' ? 'share' : 'api')); + }); + + // Adding or editing one server. The key is checked against Immich before it is + // stored, so every saved server is one that answered — and the probe's version + // and download answer are kept with it, which is what lets the tree draw + // without asking Immich anything. + app.put('/api/immich/config', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const b = body(req); + const servers = immichServers(user.id); + const existing = typeof b.id === 'string' ? servers.find((s) => s.id === b.id) : undefined; + if (b.id !== undefined && !existing) return reply.status(404).send({ error: 'unknown server' }); + + // A partial edit (rename, retick the albums) keeps what it does not carry. + const url = cleanUrl(b.url) ?? existing?.url ?? null; + const key = cleanKey(b.key) ?? existing?.key ?? null; + const type: 'api' | 'share' = b.type === 'share' || (existing?.type === 'share' && b.type === undefined) ? 'share' : 'api'; + if (!url || !key) return reply.status(400).send({ error: 'url and key are required' }); + + const probed = await probe(url, key, type); + if (!probed.ok) return reply.send(probed); + + const entry: ImmichServer = { + id: existing?.id ?? randomBytes(8).toString('hex'), + name: cleanName(b.name ?? existing?.name, url), + url, + key, + type, + version: probed.version, + canDownload: probed.canDownload, + albums: 'albums' in b ? cleanAlbums(b.albums) : existing?.albums ?? [], + }; + setImmichServers(user.id, existing ? servers.map((s) => (s.id === entry.id ? entry : s)) : [...servers, entry]); + return reply.send({ ok: true, server: view(entry) }); + }); + + // Forgetting one server: the key goes with it, and the synced rows in the + // browser's own catalogue become unreachable, which is what the client shows. + app.delete('/api/immich/config', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const id = (req.query as { id?: string } | undefined)?.id; + const servers = immichServers(user.id); + const kept = servers.filter((s) => s.id !== id); + if (!id || kept.length === servers.length) return reply.status(404).send({ error: 'unknown server' }); + setImmichServers(user.id, kept); + return reply.send({ ok: true, servers: kept.map(view) }); + }); + + // The ticked albums. An empty list is a decision, not a missing value: it + // means "every album this key can see" (see the plan, the album-ID field). + app.put('/api/immich/albums', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const b = body(req); + const servers = immichServers(user.id); + const server = servers.find((s) => s.id === b.id); + if (!server) return reply.status(404).send({ error: 'unknown server' }); + server.albums = cleanAlbums(b.selected); + setImmichServers(user.id, servers); + return reply.send({ ok: true, albums: server.albums }); + }); + + // The live album list, for the dialog that ticks them. Deliberately not cached + // anywhere: a new album on Immich has to appear the next time it is opened. + app.get('/api/immich/albums', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const server = pick(user, reply, (req.query as { id?: string }).id); + if (!server) return; + const albums = await listAlbums(server); + if (!Array.isArray(albums)) return reply.status(502).send(albums); + return reply.send({ ok: true, albums }); + }); + + // One page of photos: an album's, or — with no album at all — everything the + // key can see, which is the "read all of it" case of the album-ID field. A + // share link is one album read whole; an API key searches. Both answer in the + // same page shape, so the client's sync loop has one case. + app.get('/api/immich/assets', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const q = req.query as Record; + const server = pick(user, reply, q.server); + if (!server) return; + const page = Math.max(1, Math.trunc(Number(q.page ?? 1)) || 1); + const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(Number(q.size ?? 100)) || 100)); + const album = q.album && q.album !== 'all' ? q.album : null; + if (album && !UUID_RE.test(album)) return reply.status(400).send({ error: 'invalid album id' }); + + if (server.type === 'share') { + const res = await call(server, { path: '/shared-links/me' }); + if (!res.ok) return reply.status(502).send(refusal(res)); + const me = await jsonOf<{ assets?: RawAsset[] }>(res); + const all = (me?.assets ?? []).filter((a) => a.type === 'IMAGE' || a.type === undefined); + const start = (page - 1) * size; + return reply.send({ + ok: true, + page, + size, + total: all.length, + hasMore: start + size < all.length, + items: all.slice(start, start + size).map(assetRow), + }); + } + + const res = await call(server, { + method: 'POST', + path: '/search/metadata', + json: { size, page, withExif: true, type: 'IMAGE', ...(album ? { albumIds: [album] } : {}) }, + }); + if (!res.ok) return reply.status(502).send(refusal(res)); + const found = await jsonOf<{ assets?: { items?: RawAsset[]; total?: number } }>(res); + const items = found?.assets?.items ?? []; + return reply.send({ + ok: true, + page, + size, + total: found?.assets?.total ?? null, + // The next page is only asked for while a full one came back: that is the + // one shape every Immich version agrees on (no `nextPage`, no `nextCursor`). + hasMore: items.length === size, + items: items.map(assetRow), + }); + }); + + // An Immich-generated image, streamed straight through. Always the size the + // caller asked for: the grid asks for `thumbnail`, the stage for `preview`, + // and only the studio ever asks for the original. + app.get('/api/immich/thumb', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const q = req.query as Record; + const server = pick(user, reply, q.server); + if (!server) return; + if (!UUID_RE.test(q.id ?? '')) return reply.status(400).send({ error: 'invalid asset id' }); + const size = THUMB_SIZES.has(q.size ?? '') ? (q.size as string) : 'thumbnail'; + + const res = await call(server, { path: `/assets/${q.id}/thumbnail`, query: { size }, timeout: FETCH_MS }); + if (!res.ok || !res.body) return reply.status(res.status === 404 ? 404 : 502).send(refusal(res)); + reply.header('content-type', res.headers.get('content-type') ?? 'image/webp'); + // The same bytes for every visitor of this account and a day of browser + // cache: the grid scrolls on the second look without touching Immich. + reply.header('cache-control', 'private, max-age=86400'); + return reply.send(Readable.fromWeb(res.body as Parameters[0])); + }); + + // The file the studio is about to edit, fetched once and never cached: it is + // by far the largest thing this API ever sends. + app.get('/api/immich/original', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const q = req.query as Record; + const server = pick(user, reply, q.server); + if (!server) return; + if (!UUID_RE.test(q.id ?? '')) return reply.status(400).send({ error: 'invalid asset id' }); + + const res = await call(server, { path: `/assets/${q.id}/original`, timeout: FETCH_MS }); + if (!res.ok || !res.body) return reply.status(res.status === 404 ? 404 : 502).send(refusal(res)); + reply.header('content-type', res.headers.get('content-type') ?? 'application/octet-stream'); + const length = res.headers.get('content-length'); + if (length) reply.header('content-length', length); + reply.header('cache-control', 'no-store'); + return reply.send(Readable.fromWeb(res.body as Parameters[0])); + }); +} diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index fd95d52..df6ecd7 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -1,6 +1,7 @@ import { recipeFile } from './recipeFile'; import { sendVerificationMail } from './mailer'; import { placeName } from './place'; +import { immichRoutes } from './immich'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import { exec, spawn, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; @@ -1164,6 +1165,15 @@ app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply return reply.status(200).send({ id, slots: set }); }); +// ---- Immich, as a second photo source --------------------------------------- +// The LIBRARY module may carry Immich albums beside the folders on disk. The +// browser cannot talk to Immich itself (the SPA is served with COEP require-corp +// and an Immich key must not reach it), so the routes behind /api/immich/* +// proxy it, read-only, with each account's own key kept in the users table. +// Nothing here is required for the rest of the API: with no server added, the +// dialog is the only thing that ever calls these. +immichRoutes(app, requireMember); + // ---- backup / restore ----------------------------------------------------- // The deployment's whole state is DATA_DIR: one SQLite file plus the two media // folders. The archive is a plain tar.gz of exactly those three, which makes diff --git a/docker/backend/test/immich.mjs b/docker/backend/test/immich.mjs new file mode 100644 index 0000000..a73e19e --- /dev/null +++ b/docker/backend/test/immich.mjs @@ -0,0 +1,376 @@ +// Immich proxy self-check for the API. Boots the real server against a +// throwaway DATA_DIR and a fake Immich on loopback, then walks the boundaries +// that matter: who may reach the proxy, what a key is allowed to become, and +// what the browser is allowed to learn about it. +// +// npm run test:immich (from docker/backend/) +// +// Node only — no test framework, no network beyond loopback. The fake Immich is +// deliberately strict: it refuses any request that does not carry the key it +// expects, so a route that forgets to attach one fails here rather than in the +// LIBRARY. +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const PORT = Number(process.env.TEST_PORT || 3412); +const BASE = `http://127.0.0.1:${PORT}/api`; +const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-immich-')); + +const KEY = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaabcd'; +const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without asset.download +const SHARE_KEY = 'share-key-1234567890'; +const ALBUM_A = '11111111-1111-4111-8111-111111111111'; +const ALBUM_B = '22222222-2222-4222-8222-222222222222'; +const assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; +const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes'); +const ORIGINAL = Buffer.from('II*\0fake-original-bytes'); + +const asset = (n, album) => ({ + id: assetId(n), + type: 'IMAGE', + originalFileName: `photo-${n}.jpg`, + fileCreatedAt: `2026-01-0${n}T10:00:00.000Z`, + width: 4000, + height: 3000, + exifInfo: { fileSizeInByte: 1000 * n }, + album, +}); + +let pass = 0; +let fail = 0; +const check = (name, ok, detail = '') => { + if (ok) { + pass++; + console.log(`PASS ${name}`); + } else { + fail++; + console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`); + } +}; + +// ---- the fake Immich -------------------------------------------------------- +// Every request is recorded, so "did the proxy attach the key, and did it ask +// for the size it promised?" is an assertion rather than a hope. +const seen = []; +function startFake() { + const server = createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + const apiKey = req.headers['x-api-key']; + const shareKey = url.searchParams.get('key'); + seen.push({ + path: url.pathname, + size: url.searchParams.get('size'), + apiKey: apiKey ?? null, + shareKey: shareKey ?? null, + key: apiKey ?? shareKey ?? null, + range: req.headers.range ?? null, + }); + const send = (status, payload, type = 'application/json') => { + res.writeHead(status, { 'content-type': type }); + res.end(Buffer.isBuffer(payload) ? payload : JSON.stringify(payload)); + }; + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; + const authed = apiKey === KEY || apiKey === KEY_NODL; + // A share link is a key too, but only Immich's asset routes read it from + // `?key=` — the album and search routes are API-key territory. + const shareAuthed = shareKey === SHARE_KEY; + + if (url.pathname === '/api/server/version') return send(200, { major: 3, minor: 1, patch: 0, prerelease: null }); + + if (url.pathname === '/api/shared-links/me') { + if (shareKey !== SHARE_KEY) return send(401, { message: 'Authentication required' }); + return send(200, { + id: 'link-1', + album: { id: ALBUM_B, albumName: 'Album chia sẻ', assetCount: 2 }, + allowDownload: true, + assets: [asset(5), asset(6)], + }); + } + + if (url.pathname === '/api/albums') { + if (!authed) return send(401, { message: 'Authentication required' }); + return send(200, [ + { id: ALBUM_A, albumName: 'Nhà', assetCount: 2, albumThumbnailAssetId: assetId(1), shared: false }, + { id: ALBUM_B, albumName: 'Du lịch', assetCount: 1, albumThumbnailAssetId: assetId(3), shared: true }, + ]); + } + + if (url.pathname === '/api/search/metadata') { + const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)]; + const filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all; + const size = Number(body.size ?? 100); + const page = Number(body.page ?? 1); + const items = filtered.slice((page - 1) * size, page * size); + return send(200, { + albums: { items: [], total: 0 }, + assets: { items, total: filtered.length, count: items.length, nextPage: null }, + }); + } + + if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' }); + + const thumb = url.pathname.match(/^\/api\/assets\/([^/]+)\/thumbnail$/); + if (thumb) { + if (![1, 2, 3, 5, 6].some((n) => assetId(n) === thumb[1])) return send(404, { message: 'Not found' }); + return send(200, THUMB, 'image/webp'); + } + + const original = url.pathname.match(/^\/api\/assets\/([^/]+)\/original$/); + if (original) { + if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: asset.download' }); + if (req.headers.range) { + res.writeHead(206, { 'content-type': 'image/jpeg', 'content-range': 'bytes 0-0/24' }); + return res.end(ORIGINAL.subarray(0, 1)); + } + return send(200, ORIGINAL, 'image/jpeg'); + } + + return send(404, { message: 'Not found' }); + }); + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }))); +} + +// ---- one cookie jar per actor, as in test/security.mjs ---------------------- +function actor() { + let cookie = ''; + return { + get cookie() { + return cookie; + }, + async req(path, init = {}) { + const headers = { ...(init.headers ?? {}) }; + if (cookie) headers.cookie = cookie; + const res = await fetch(BASE + path, { ...init, headers }); + const set = res.headers.getSetCookie?.() ?? []; + for (const line of set) { + const value = line.split(';')[0]; + if (value.startsWith('rc_session=')) cookie = value; + } + const type = res.headers.get('content-type') ?? ''; + const body = type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer()); + return { status: res.status, headers: res.headers, body }; + }, + post(path, payload) { + return this.req(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + put(path, payload) { + return this.req(path, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + async signup(email) { + const res = await this.post('/auth/signup', { email, password: 'supersecret1' }); + if (res.status !== 201) throw new Error(`signup for ${email} answered ${res.status}`); + return res; + }, + }; +} + +const fake = await startFake(); +const tsx = join(ROOT, 'node_modules/.bin/tsx'); +const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js']; +const server = spawn(process.execPath, entry, { + cwd: ROOT, + env: { + ...process.env, + PORT: String(PORT), + DATA_DIR, + NODE_ENV: 'test', + IMMICH_URL: `http://127.0.0.1:${fake.port}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let serverLog = ''; +server.stdout.on('data', (d) => (serverLog += d)); +server.stderr.on('data', (d) => (serverLog += d)); + +async function waitForServer() { + for (let i = 0; i < 100; i++) { + try { + if ((await fetch(`${BASE}/health`)).ok) return true; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 100)); + } + return false; +} + +const url = `http://127.0.0.1:${fake.port}`; + +try { + if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`); + const stamp = Date.now(); + const user = actor(); + const other = actor(); + await user.signup(`immich${stamp}@test.local`); + await other.signup(`immich-other${stamp}@test.local`); + + // ---- who may reach the proxy at all -------------------------------------- + const anonymous = await fetch(`${BASE}/immich/config`); + check('the proxy refuses a signed-out caller', anonymous.status === 401, `got ${anonymous.status}`); + check('the proxy refuses a signed-out thumb', (await fetch(`${BASE}/immich/thumb?server=x&id=${assetId(1)}`)).status === 401); + + // ---- the "check this key" probe ----------------------------------------- + const noKey = await user.post('/immich/probe', { url }); + check('a probe without a key is refused', noKey.status === 400 && noKey.body?.error === 'invalid', JSON.stringify(noKey.body)); + const badScheme = await user.post('/immich/probe', { url: 'file:///etc', key: KEY }); + check('a probe refuses a non-http address', badScheme.status === 400, JSON.stringify(badScheme.body)); + const dead = await user.post('/immich/probe', { url: 'http://127.0.0.1:1', key: KEY }); + check('a probe of a dead host answers unreachable', dead.body?.error === 'unreachable', JSON.stringify(dead.body)); + const wrongKey = await user.post('/immich/probe', { url, key: 'nope' }); + check('a probe with a wrong key answers auth', wrongKey.body?.error === 'auth', JSON.stringify(wrongKey.body)); + + const good = await user.post('/immich/probe', { url, key: KEY }); + check('a probe reports the server version', good.body?.version === '3.1.0', JSON.stringify(good.body?.version)); + check('a probe lists the albums the key sees', good.body?.albums?.length === 2, JSON.stringify(good.body?.albums)); + check('a probe reports the album count', good.body?.albums?.[0]?.assetCount === 2, JSON.stringify(good.body?.albums?.[0])); + check('a probe reports download permission', good.body?.canDownload === true, JSON.stringify(good.body?.canDownload)); + check('a probe of a key without asset.download says no', (await user.post('/immich/probe', { url, key: KEY_NODL })).body?.canDownload === false); + check('a probe stores nothing', (await user.req('/immich/config')).body?.servers?.length === 0); + + // ---- storing a server ---------------------------------------------------- + const saved = await user.put('/immich/config', { url, key: KEY, name: 'nas' }); + const serverId = saved.body?.server?.id; + check('a server is saved', saved.status === 200 && typeof serverId === 'string', JSON.stringify(saved.body)); + check('the saved name is kept', saved.body?.server?.name === 'nas', JSON.stringify(saved.body?.server?.name)); + check('the key comes back masked', saved.body?.server?.keyMasked === '••••abcd', JSON.stringify(saved.body?.server?.keyMasked)); + check('the key never comes back in clear', !JSON.stringify(saved.body).includes(KEY), JSON.stringify(saved.body)); + + const config = await user.req('/immich/config'); + check('the saved list carries the deployment default address', config.body?.defaultUrl === url, JSON.stringify(config.body?.defaultUrl)); + check('the saved server is listed with its version', config.body?.servers?.[0]?.version === '3.1.0', JSON.stringify(config.body?.servers?.[0])); + + // A server saved without a name falls back to its host, which is what the tree + // shows beside the node. + const unnamed = await user.put('/immich/config', { url, key: KEY }); + check('an unnamed server is named after its host', unnamed.body?.server?.name === '127.0.0.1', JSON.stringify(unnamed.body?.server?.name)); + check('the same address twice is two servers', (await user.req('/immich/config')).body?.servers?.length === 2); + + // ---- one account cannot touch another's servers -------------------------- + check("another account sees none of it", (await other.req('/immich/config')).body?.servers?.length === 0); + const stolen = await other.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`); + check("another account's server id is unknown", stolen.status === 404, `got ${stolen.status}`); + const stolenDelete = await other.req(`/immich/config?id=${serverId}`, { method: 'DELETE' }); + check("another account cannot delete it", stolenDelete.status === 404, `got ${stolenDelete.status}`); + const stolenEdit = await other.put('/immich/albums', { id: serverId, selected: [ALBUM_A] }); + check("another account cannot retick its albums", stolenEdit.status === 404, `got ${stolenEdit.status}`); + + // ---- ticking albums, and the pasted IDs --------------------------------- + const ticked = await user.put('/immich/albums', { + id: serverId, + selected: [ALBUM_A, `https://immich.example/albums/${ALBUM_B}`, ALBUM_A, 'not-a-uuid', ALBUM_B.toUpperCase()], + }); + check( + 'pasted ids are cleaned, deduped and capped', + JSON.stringify(ticked.body?.albums) === JSON.stringify([ALBUM_A, ALBUM_B]), + JSON.stringify(ticked.body?.albums), + ); + const empty = await user.put('/immich/albums', { id: serverId, selected: [] }); + check('an empty tick list is kept as "every album"', Array.isArray(empty.body?.albums) && empty.body.albums.length === 0); + + // ---- the page of photos ------------------------------------------------- + const live = await user.req(`/immich/albums?id=${serverId}`); + check('the live album list is filtered to the fields the tree needs', live.body?.albums?.[0]?.albumName === 'Nhà', JSON.stringify(live.body?.albums?.[0])); + check('the live album list keeps the count', live.body?.albums?.[1]?.assetCount === 1, JSON.stringify(live.body?.albums?.[1])); + + const albumPage = await user.req(`/immich/assets?server=${serverId}&album=${ALBUM_A}&page=1&size=10`); + check('an album page returns that album only', albumPage.body?.items?.length === 2 && albumPage.body.total === 2, JSON.stringify(albumPage.body)); + check('a row carries what the catalogue needs', albumPage.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(albumPage.body?.items?.[0])); + check('a short page ends the walk', albumPage.body?.hasMore === false, JSON.stringify(albumPage.body?.hasMore)); + + const allPhotos = await user.req(`/immich/assets?server=${serverId}&album=all&page=1&size=10`); + check('no album means every photo the key sees', allPhotos.body?.items?.length === 3, JSON.stringify(allPhotos.body?.items?.length)); + const paged = await user.req(`/immich/assets?server=${serverId}&page=2&size=2`); + check('pages divide the same list', paged.body?.items?.length === 1 && paged.body?.hasMore === false, JSON.stringify(paged.body)); + + const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`); + check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`); + + // ---- the pixels --------------------------------------------------------- + const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`); + check('a tile streams through the proxy', tile.status === 200 && tile.body.equals(THUMB), `got ${tile.status}`); + check('a tile keeps Immich own content type', tile.headers.get('content-type') === 'image/webp', tile.headers.get('content-type') ?? ''); + check('a tile is cacheable for a day', /max-age=86400/.test(tile.headers.get('cache-control') ?? ''), tile.headers.get('cache-control') ?? ''); + check('a tile defaults to the small size', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1))); + check('a tile travels with the key', seen.at(-1)?.key === KEY, JSON.stringify(seen.at(-1))); + await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=preview`); + check('the stage size is passed through', seen.at(-1)?.size === 'preview', JSON.stringify(seen.at(-1))); + await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=../../etc`); + check('an unknown size falls back to the small one', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1))); + + const badAsset = await user.req(`/immich/thumb?server=${serverId}&id=../secret`); + check('a malformed asset id never reaches Immich', badAsset.status === 400, `got ${badAsset.status}`); + const missingAsset = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(9)}`); + check('a missing asset stays a 404', missingAsset.status === 404, `got ${missingAsset.status}`); + + const original = await user.req(`/immich/original?server=${serverId}&id=${assetId(1)}`); + check('an original streams through the proxy', original.status === 200 && original.body.equals(ORIGINAL), `got ${original.status}`); + check('an original is never cached', original.headers.get('cache-control') === 'no-store', original.headers.get('cache-control') ?? ''); + + // The studio is the only caller of an original, so a key without + // asset.download has to be a clear refusal rather than a broken download. + const noDownload = await user.put('/immich/config', { url, key: KEY_NODL, name: 'reader' }); + const readerId = noDownload.body?.server?.id; + check('a key without download is still accepted', noDownload.status === 200 && noDownload.body?.server?.canDownload === false, JSON.stringify(noDownload.body)); + const refused = await user.req(`/immich/original?server=${readerId}&id=${assetId(1)}`); + check('its original is refused with the reason', refused.status === 502 && refused.body?.error === 'auth', JSON.stringify(refused.body)); + + // ---- a share link is one album ------------------------------------------ + const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' }); + check('a share key probes into its own single album', shareProbe.body?.albums?.length === 1, JSON.stringify(shareProbe.body?.albums)); + check('the shared album is named from Immich', shareProbe.body?.albums?.[0]?.albumName === 'Album chia sẻ', JSON.stringify(shareProbe.body?.albums?.[0])); + const shareSaved = await user.put('/immich/config', { url, key: SHARE_KEY, type: 'share', name: 'Chia sẻ' }); + const shareId = shareSaved.body?.server?.id; + check('a share server is stored', shareSaved.status === 200 && typeof shareId === 'string', JSON.stringify(shareSaved.body)); + const shareAssets = await user.req(`/immich/assets?server=${shareId}&page=1&size=10`); + check('a share key lists its album whole', shareAssets.body?.items?.length === 2, JSON.stringify(shareAssets.body)); + const shareTile = await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`); + check('a share tile streams too', shareTile.status === 200 && shareTile.body.equals(THUMB), `got ${shareTile.status}`); + check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1))); + check('a share key is never sent as an x-api-key header', seen.at(-1)?.apiKey === null, JSON.stringify(seen.at(-1))); + + // ---- forgetting a server ------------------------------------------------- + const forgotten = await user.req(`/immich/config?id=${shareId}`, { method: 'DELETE' }); + check('a server can be forgotten', forgotten.status === 200 && forgotten.body?.servers?.length === 3, JSON.stringify(forgotten.body?.servers?.length)); + check('its id stops working', (await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`)).status === 404); + const unknownDelete = await user.req('/immich/config?id=deadbeef', { method: 'DELETE' }); + check('forgetting an unknown server is a 404', unknownDelete.status === 404, `got ${unknownDelete.status}`); + + // ---- no key ever reaches the browser ------------------------------------ + const everything = JSON.stringify([ + config.body, + saved.body, + (await user.req('/immich/config')).body, + live.body, + albumPage.body, + ]); + check('no route echoes a key', !everything.includes(KEY) && !everything.includes(SHARE_KEY)); + check('the server log holds no key either', !serverLog.includes(KEY) && !serverLog.includes(SHARE_KEY)); +} catch (err) { + fail++; + console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`); + console.log(serverLog.slice(-2000)); +} finally { + server.kill('SIGTERM'); + fake.server.close(); + rmSync(DATA_DIR, { recursive: true, force: true }); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d704c81..47a4fd5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -23,6 +23,11 @@ services: SMTP_PASS: ${SMTP_PASS:-} SMTP_FROM: ${SMTP_FROM:-} SMTP_SECURE: ${SMTP_SECURE:-} + # Immich, offered as a second photo source in LIBRARY. This is only the + # address the "add an Immich server" dialog starts with: the URL and the + # key belong to each account, are typed in the app, and are stored in the + # users table. Empty simply means the field starts blank. + IMMICH_URL: ${IMMICH_URL:-} volumes: # SQLite (WAL) lives on the host so a rebuild never loses accounts. - ./data:/data