Files
RecipesCam/docker/backend/src/immich.ts
T

668 lines
29 KiB
TypeScript

// 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;
// What the shelf's search box may carry. Bounded because it goes on to another
// box as a query, and a search box is a thing people paste into.
const MAX_QUERY = 100;
// 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));
}
// The album this app keeps for its own uploads: made once, on the first upload
// that has no album of its own to go to, then found by name every time after.
// ponytail: found by name, not remembered as an id — an album renamed or deleted
// on Immich is followed (and remade) rather than fought; cache the id in
// `server.albums` if the extra list on every upload ever shows up in a profile.
const APP_ALBUM = 'RecipesCam';
async function appAlbumId(server: ImmichServer): Promise<string | null> {
const albums = await listAlbums(server);
const found = Array.isArray(albums)
? albums.find((album) => album.albumName.trim().toLowerCase() === APP_ALBUM.toLowerCase())
: undefined;
if (found?.id) return found.id;
// A key without `album.create` is refused here, and the photo keeps the
// timeline-only fate it had before: upload is not what failed.
const res = await call(server, { method: 'POST', path: '/albums', json: { albumName: APP_ALBUM } });
if (!res.ok) return null;
return (await jsonOf<RawAlbum>(res))?.id ?? null;
}
// The tags this key can see whose name holds what was typed. Asked for on every
// search rather than cached: a tag made on Immich has to be found by the next
// one, and the list is small. A key without `tag.read` is answered 403, which is
// a key that cannot search by tag — not a search that failed.
async function tagIdsMatching(server: ImmichServer, q: string): Promise<string[]> {
const res = await call(server, { path: '/tags' });
if (!res.ok) return [];
const tags = (await jsonOf<{ id?: string; name?: string }[]>(res)) ?? [];
const needle = q.toLowerCase();
return tags.filter((tag) => tag.id && (tag.name ?? '').toLowerCase().includes(needle)).map((tag) => tag.id as string);
}
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' && id ? servers.find((s) => s.id === id) : servers[0];
if (!server) {
void reply.status(404).send({ error: 'unknown server' });
return undefined;
}
return server;
};
// The saved list. No upstream call at all: the tree has to draw on a phone with
// no signal. Nothing here suggests an address — the address and the key are each
// account's own, typed in the app, so a fresh server is a blank field.
app.get('/api/immich/config', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
return reply.send({ ok: true, 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' });
// What the shelf's search box carries: words to look for in a file's own name,
// or the name of a tag. A share link has no search of its own, so its one
// album is sifted here instead.
const search = (q.q ?? '').trim().slice(0, MAX_QUERY);
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 needle = search.toLowerCase();
const all = (me?.assets ?? [])
.filter((a) => a.type === 'IMAGE' || a.type === undefined)
.filter((a) => !needle || (a.originalFileName ?? '').toLowerCase().includes(needle));
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),
});
}
if (album) {
// First try GET /albums/:id (supported across many Immich versions)
const albRes = await call(server, { path: `/albums/${album}` });
if (albRes.ok) {
const albData = await jsonOf<{ assets?: RawAsset[] }>(albRes);
if (Array.isArray(albData?.assets) && albData.assets.length > 0) {
const allAssets = albData.assets.filter((a) => a.type === 'IMAGE' || a.type === undefined);
const start = (page - 1) * size;
return reply.send({
ok: true,
page,
size,
total: allAssets.length,
hasMore: start + size < allAssets.length,
items: allAssets.slice(start, start + size).map(assetRow),
});
}
}
}
// One query, one answer. Immich reads every field it is handed as one more
// thing an asset must be — `originalFileName` with `tagIds` is a file called
// this *and* tagged that — so the box is read as a tag when it names one, and
// as a file name otherwise, which keeps the page a page of one list.
// ponytail: a query that names a tag finds only that tag's frames; fold the
// two answers into one list if a reader ever needs both at once.
const tagIds = search ? await tagIdsMatching(server, search) : [];
const res = await call(server, {
method: 'POST',
path: '/search/metadata',
timeout: 60_000,
json: {
size,
page,
withExif: true,
type: 'IMAGE',
...(album ? { albumIds: [album] } : {}),
...(q.takenBefore ? { takenBefore: q.takenBefore } : {}),
...(q.takenAfter ? { takenAfter: q.takenAfter } : {}),
...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}),
},
});
if (!res.ok) return reply.status(502).send(refusal(res));
const found = await jsonOf<any>(res);
// Robust parsing across all Immich versions:
// Immich v1: found.assets.items
// Immich v2+: found.items
// Raw array: Array.isArray(found)
const rawItems: RawAsset[] = found?.assets?.items ?? found?.items ?? (Array.isArray(found) ? found : []);
const total: number | null = found?.assets?.total ?? found?.total ?? found?.count ?? null;
// What says another page is there: a page that came back full, or the
// server's own word for it. `assets.total` is not the size of the library —
// in Immich v2 it is the size of the page it came with — and read as the
// library it made every page but one look like there was more behind it.
const hasMore: boolean =
rawItems.length === size ||
(typeof found?.nextCursor === 'string' && !!found.nextCursor) ||
(typeof found?.assets?.nextPage === 'string' && !!found.assets.nextPage) ||
(typeof found?.nextPage === 'string' && !!found.nextPage);
return reply.send({
ok: true,
page,
size,
total,
hasMore,
items: rawItems.map(assetRow),
});
});
// The timeline buckets (years/months), so the client knows all available years and photo counts in 1 call.
app.get('/api/immich/buckets', 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 size = q.size === 'YEAR' ? 'YEAR' : 'MONTH';
const res = await call(server, { path: '/timeline/buckets', query: { size }, timeout: 15_000 });
if (!res.ok) return reply.status(502).send(refusal(res));
const buckets = await jsonOf<any>(res);
return reply.send({ ok: true, buckets: Array.isArray(buckets) ? buckets : [] });
});
// 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]));
});
// Upload an edited photo from Studio to Immich Cloud
app.post(
'/api/immich/upload',
{ bodyLimit: 50 * 1024 * 1024 },
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 (server.type !== 'api') {
return reply.status(400).send({ error: 'Upload is only supported for API-key Immich connections' });
}
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) {
return reply.status(400).send({ error: 'invalid body or empty photo' });
}
const rawName = (q.name ?? '').trim() || `recipescam_${Date.now()}.jpg`;
const fileName = rawName.endsWith('.jpg') || rawName.endsWith('.jpeg') || rawName.endsWith('.png') ? rawName : `${rawName}.jpg`;
const form = new FormData();
form.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
form.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
form.append('deviceId', 'RecipesCam');
form.append('fileCreatedAt', new Date().toISOString());
form.append('fileModifiedAt', new Date().toISOString());
form.append('isFavorite', 'false');
try {
let immichRes = await fetch(new URL(`${server.url}/api/assets`), {
method: 'POST',
headers: {
'x-api-key': server.key,
},
body: form,
signal: AbortSignal.timeout(60_000),
});
if (immichRes.status === 404) {
// Fallback for older Immich versions (< 1.106)
const fallbackForm = new FormData();
fallbackForm.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
fallbackForm.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
fallbackForm.append('deviceId', 'RecipesCam');
fallbackForm.append('fileCreatedAt', new Date().toISOString());
fallbackForm.append('fileModifiedAt', new Date().toISOString());
fallbackForm.append('isFavorite', 'false');
immichRes = await fetch(new URL(`${server.url}/api/asset/upload`), {
method: 'POST',
headers: {
'x-api-key': server.key,
},
body: fallbackForm,
signal: AbortSignal.timeout(60_000),
});
}
if (!immichRes.ok) {
const errText = await immichRes.text().catch(() => '');
return reply.status(immichRes.status).send({ error: 'Immich upload failed', details: errText });
}
const data = await immichRes.json().catch(() => ({}));
const assetId = (data as any)?.id;
// Where the photo should land: the album asked for, else the single album
// this account ticked, else the one album the app keeps for itself — made
// on the spot the first time. No key that can hold albums? The photo keeps
// the timeline, as it always did.
const targetAlbum =
q.album || (server.albums?.length === 1 ? server.albums[0] : undefined) || (assetId ? await appAlbumId(server) : undefined);
if (targetAlbum && assetId) {
try {
await fetch(new URL(`${server.url}/api/albums/${targetAlbum}/assets`), {
method: 'PUT',
headers: {
'x-api-key': server.key,
'content-type': 'application/json',
},
body: JSON.stringify({ ids: [assetId] }),
signal: AbortSignal.timeout(10_000),
});
} catch {}
}
return reply.send({ ok: true, id: assetId, result: data });
} catch (err: any) {
return reply.status(502).send({ error: 'Failed to connect to Immich server', details: String(err) });
}
}
);
}