feat(immich): read Immich through a per-user read-only proxy
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
// 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/<uuid>` 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<string, string | number | undefined>;
|
||||
json?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
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<Response> {
|
||||
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<string, string> = { ...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<T>(res: Response): Promise<T | null> {
|
||||
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<AlbumRow[] | Failure> {
|
||||
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<RawAlbum[]>(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album));
|
||||
}
|
||||
|
||||
async function firstAssetId(server: ImmichServer): Promise<string | null> {
|
||||
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<boolean | null> {
|
||||
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<ProbeResult> {
|
||||
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<string, string | undefined>;
|
||||
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<string, string | undefined>;
|
||||
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<typeof Readable.fromWeb>[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<string, string | undefined>;
|
||||
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<typeof Readable.fromWeb>[0]));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user