Files
immich-folio/lib/config/index.ts
Ralksta 716ae14b0f Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397)
* docs: brainstorm resilience/adoption features and plan follow-up work

Brainstorm and deep-dive on where Immich Folio breaks under Immich
outages and what blocks self-hosted adoption, plus an implementation
plan for the remaining gaps after v0.9.2 (route-handler tests,
stale-while-error caching, loading skeletons).

* test: enable route-handler tests and cover admin guards and image content-type

vitest.config.ts only ran lib/__tests__/**, so route handlers were
structurally untestable. Two mechanisms live only at the route level and
cannot be extracted the way lib/imageSize.ts was: the admin auth guards
and the image proxy's Content-Type sanitisation (the stored-XSS fix from
c2fa8e7). Both were correct but unverified by any test.

Adds a table-driven test over all ten guarded /api/admin/* handlers,
including a filesystem walk that fails if a future route is added
without a row in the table. Adds a route-level test for
/api/image/[id] covering token validation, the six Content-Type
rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError)
distinction.

Verified both catch real regressions by temporarily removing the admin
guards and the Content-Type rewrite and confirming the expected tests
fail, then restoring and confirming green again.

134 new tests (244 total, up from 210), tsc 0 errors, build succeeds.

* feat: serve stale cache entries while Immich is unavailable

v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable"
(ImmichUnavailableError), and pages now render a 5xx instead of a false
404 during an outage — but that 5xx still replaced a gallery that had
loaded fine seconds earlier. lib/cache.ts deleted expired entries on
access, so nothing survived long enough to fall back to.

Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL,
after which get() still reports a miss) and hardExpiresAt (the new
STALE_MAX_AGE, default 24h, after which the entry is dropped entirely).
Between the two, getStale() can still return the data.

getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError
and fall back to getStale() before propagating. The two MISSING-sentinel
writes (definitive 404s) are deliberately left on the short cacheTtl
window — a corrected asset ID should take effect after the normal TTL,
not sit in the 24h stale window meant for outages.

STALE_MAX_AGE=0 disables the fallback. Verified the fallback only
engages on a real outage, not on a cache hit at the TTL boundary, and
that a definitive 404 still reports missing rather than falling back.

256 tests (up from 244), tsc 0 errors, build succeeds.

* feat: add loading skeletons for dynamic gallery routes

Every gallery route is force-dynamic and waits on a live Immich
round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling —
with nothing rendered until it resolves. loading.tsx renders instantly
while the page component awaits its data.

Not browser-verified against live data: this worktree has no
.env.local and only .example content files, so the app boots into
setup mode (confirmed the dev server starts cleanly and the setup
screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint
errors.

* docs: document stale cache window, image token revocation, route-test convention

- README: STALE_MAX_AGE env var and a "Behaviour when Immich is
  unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404,
  outages never cached).
- docs/gallery-config.md: new "Image Token Revocation" subsection under
  System & Security — un-publishing an album does not revoke already
  issued image URLs; rotating AUTH_SECRET does.
- CLAUDE.md: updated cache description to cover the stale window and
  MISSING sentinel, and a new code-convention note that route-level
  logic (guards, header sanitisation) should get a route-level test
  rather than being extracted into lib/ just to reach the test runner.

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00

367 lines
12 KiB
TypeScript

import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme';
import {
slugify,
AppConfig,
SubpageConfig,
SubpageSectionConfig,
SubpageObjectValue,
GalleryYaml,
SettingsYaml,
GridConfig,
} from './schema';
export * from './schema';
export * from './theme';
/** Invalidate the cached YAML so the next getConfig() call re-parses the files. */
export function invalidateConfigCache(): void {
clearYamlCache();
}
/**
* getConfig(), but returns null instead of throwing on a gallery.yaml that
* cannot be derived.
*
* getConfig() throws for three shapes the admin page builder is able to write:
* a gallery with neither albums nor subpages, a subpage with no name, and a
* subpage with neither albums nor sections. A throw inside a *layout* is not
* caught by app/error.tsx — that needs a global-error boundary — so the site
* and /admin would go down together, locking the user out of the only tool that
* can undo the save. That is issue #326's failure mode reached through an
* invalid config rather than a missing one.
*
* Callers should treat null exactly like `needsSetup`: show the setup screen,
* and keep /admin reachable.
*/
export function getConfigOrNull(): AppConfig | null {
try {
return getConfig();
} catch (error) {
console.error(
'[Folio] content/gallery.yaml could not be loaded — serving the setup screen. ' +
'Fix the file or restore a backup from content/.backups/:',
error,
);
return null;
}
}
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
export function buildSubpageGrid(raw?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
}): { grid: Partial<GridConfig> } | Record<string, never> {
if (!raw) return {};
return {
grid: {
...(raw.columns != null ? { columns: raw.columns } : {}),
...(raw.gap != null ? { gap: raw.gap } : {}),
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
...(raw.layout != null
? {
layout: (VALID_LAYOUTS.includes(raw.layout)
? raw.layout
: 'masonry') as GridConfig['layout'],
}
: {}),
},
};
}
/** The parts of AppConfig that come from gallery.yaml. */
export interface GalleryDerivation {
albums: string[];
standaloneAlbums: string[];
subpages: SubpageConfig[];
albumOverrides: Record<string, string>;
albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>;
albumHeroImages: Record<string, string>;
}
/**
* Derive the gallery half of AppConfig, throwing on a structure that cannot be
* represented: no albums and no subpages, a subpage with no name, a subpage
* with neither albums nor sections.
*
* Extracted from getConfig() so the admin PUT can run the *real* derivation
* against a proposed gallery before writing it. Re-implementing the three
* checks in the route would guarantee drift; write-then-verify-then-roll-back
* would leave a window where other requests read the broken config.
*/
export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumOverrides: Record<string, string> = {};
const albumDescriptions: Record<string, string> = {};
const albumPasswords: Record<string, string> = {};
const albumHeroImages: Record<string, string> = {};
function processAlbumEntry(
entry:
| string
| Record<
string,
string | { title: string; description?: string; password?: string; heroImage?: string }
>,
context: string,
): string {
if (typeof entry === 'string') {
return validateUuid(entry, context);
}
const [uuid, value] = Object.entries(entry)[0];
const validatedUuid = validateUuid(uuid, context);
if (typeof value === 'string') {
albumOverrides[validatedUuid] = value;
} else {
if (value.title) albumOverrides[validatedUuid] = value.title;
if (value.description) albumDescriptions[validatedUuid] = value.description;
if (value.password) albumPasswords[validatedUuid] = value.password;
// The album ID (the key) is validated above; the hero asset ID is a
// separate UUID and was previously stored raw. It flows to encodeAssetId
// via app/[...path]/page.tsx, which encrypts whatever it is handed — so a
// typo here produced a well-formed URL that 404s forever, with nothing in
// the log naming the cause. Every other ID in this file goes through
// validateUuid; this one was the gap.
if (value.heroImage) {
albumHeroImages[validatedUuid] = validateUuid(
value.heroImage,
`${context} heroImage for album ${validatedUuid}`,
);
}
}
return validatedUuid;
}
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
processAlbumEntry(entry, 'gallery.yaml albums'),
);
if (
standaloneAlbumIds.length === 0 &&
(!gallery.subpages ||
(Array.isArray(gallery.subpages)
? gallery.subpages.length === 0
: Object.keys(gallery.subpages).length === 0))
) {
throw new Error('gallery.yaml must define at least one album or subpage');
}
let subpages: SubpageConfig[] = [];
if (Array.isArray(gallery.subpages)) {
subpages = gallery.subpages.map((sp) => {
if (!sp.name) {
throw new Error(`Subpage "(unnamed)" must have a name`);
}
// Parse sections if present
let sections: SubpageSectionConfig[] | undefined;
let albumIds: string[];
if (sp.sections && sp.sections.length > 0) {
sections = sp.sections.map((sec) => ({
title: sec.title,
slug: slugify(sec.title),
description: sec.description,
albumIds: sec.albums.map((entry) =>
processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`),
),
}));
// flat union of all section albums
albumIds = sections.flatMap((s) => s.albumIds);
// Also include any top-level albums (outside sections)
const topLevel = (sp.albums ?? []).map((entry) =>
processAlbumEntry(entry, `subpage "${sp.name}"`),
);
albumIds = [...topLevel, ...albumIds];
} else {
const albums = sp.albums ?? [];
if (albums.length === 0) {
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
}
albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
}
return {
name: sp.name,
slug: slugify(sp.name),
title: sp.title,
subtitle: sp.subtitle,
albumIds,
sections,
password: sp.password,
...buildSubpageGrid(sp.grid),
};
});
} else if (gallery.subpages) {
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
if (Array.isArray(value)) {
return {
name,
slug: slugify(name),
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
};
}
const sp = value as SubpageObjectValue;
const albumEntries = sp.albums || [];
return {
name,
slug: slugify(name),
title: sp.title,
subtitle: sp.subtitle,
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
password: sp.password,
...buildSubpageGrid(sp.grid),
};
});
}
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
return {
albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages,
albumOverrides,
albumDescriptions,
albumPasswords,
albumHeroImages,
};
}
export function getConfig(): AppConfig {
// No in-memory config cache: admin saves can land in a different
// worker/process than page rendering, so a per-worker cache goes stale
// until restart. Freshness comes from the mtime-checked YAML cache in
// loadYaml() — a statSync per file, negligible next to Immich API calls.
const apiUrl = env.IMMICH_API_URL;
const apiKey = env.IMMICH_API_KEY;
const authSecret = resolveAuthSecret();
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
if (!gallery || !apiKey || !apiUrl) {
// Return dummy config if gallery.yaml is missing
return {
immich: { apiUrl: `${apiUrl}/api`, apiKey },
authSecret,
albums: [],
standaloneAlbums: [],
subpages: [],
siteTitle: env.SITE_TITLE || 'Immich Folio',
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
lang: 'en',
seo: {
title: 'Setup Required',
description: 'Please configure Immich Folio',
noIndex: true,
noFollow: true,
},
heroImages: [],
exifOnHover: true,
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
theme: resolveTheme('studio'),
footer: null,
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
map: false,
transitions: false,
albumOverrides: {},
albumDescriptions: {},
albumPasswords: {},
albumHeroImages: {},
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
needsSetup: true,
};
}
const theme = resolveTheme(settings.theme);
const {
albums: allAlbumIds,
standaloneAlbums,
subpages,
albumOverrides,
albumDescriptions,
albumPasswords,
albumHeroImages,
} = deriveGallery(gallery);
return {
immich: { apiUrl: `${apiUrl}/api`, apiKey },
authSecret,
albums: allAlbumIds,
standaloneAlbums,
subpages,
siteTitle: settings.title ?? env.SITE_TITLE,
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
lang: settings.lang ?? 'en',
seo: {
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
description:
settings.seo?.description ||
settings.subtitle ||
env.SITE_SUBTITLE ||
'A curated photography portfolio',
noIndex: settings.seo?.noIndex === true,
noFollow: settings.seo?.noFollow === true,
},
heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'),
)
: [],
exifOnHover: settings.exifOnHover !== false,
grid: {
columns: settings.grid?.columns ?? 3,
gap: settings.grid?.gap ?? 12,
aspectRatio: settings.grid?.aspectRatio ?? '1',
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
? settings.grid!.layout
: 'masonry') as GridConfig['layout'],
},
theme,
footer: settings.footer
? {
name: settings.footer.name,
instagram: settings.footer.instagram,
email: settings.footer.email,
website: settings.footer.website,
}
: null,
legal: {
enabled: settings.legal?.enabled === true,
name: settings.legal?.name || '',
address: settings.legal?.address || '',
zipCity: settings.legal?.zipCity || '',
country: settings.legal?.country || '',
email: settings.legal?.email,
phone: settings.legal?.phone,
taxId: settings.legal?.taxId,
vatId: settings.legal?.vatId,
extraInfo: settings.legal?.extraInfo,
},
map: settings.map === true,
transitions: settings.transitions !== false,
albumOverrides,
albumDescriptions,
albumPasswords,
albumHeroImages,
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
needsSetup: false,
};
}