Files
immich-folio/lib/config/index.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

888 lines
33 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { resolveSiteUrl } from '../siteUrl';
import { getInstallCredentials, installFilePath } from '../install';
import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
import { LOCATION_PRECISIONS, isLocationPrecision, type LocationPrecision } from '../mapPrecision';
import {
slugify,
findAlbumSlugCollisions,
resolveExifDisplay,
resolveColorMode,
clamp,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_GAP_MAX,
AppConfig,
AlbumEntryObject,
SubpageConfig,
SubpageSectionConfig,
SubpageObjectValue,
GalleryYaml,
SettingsYaml,
GridConfig,
LegalConfig,
ContactConfig,
CONTACT_RETENTION_DEFAULT,
CONTACT_RETENTION_MAX,
isHttpUrl,
isPageRef,
type GallerySubpageYaml,
} from './schema';
import { isValidSlug } from '../journal';
import { getDictionary, resolveLocale } from '../i18n';
import type { NavEntry } from '../pages';
export * from './schema';
export * from './theme';
/** The last derived config and the inputs it was derived from; see getConfig(). */
let derived: { inputs: string; config: AppConfig } | null = null;
/** Invalidate the cached YAML so the next getConfig() call re-parses the files. */
export function invalidateConfigCache(): void {
clearYamlCache();
derived = null;
}
/**
* 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;
}
}
interface RawGridOverrides {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
}
/**
* Converts raw YAML grid overrides into a typed partial GridConfig.
*
* `columns` and `gap` are clamped here, not left for the renderer: a subpage
* or album override reaches `--grid-columns` unclamped (app/[...path]/page.tsx),
* so `columns: -1` produced an invalid `repeat(-1, 1fr)` and collapsed the
* whole grid to one column with no error anywhere (#633). The cover grid had
* this already (buildCoverGridVars); this is the same treatment for the photo
* grid it was missing from.
*/
function parseGridOverrides(raw: RawGridOverrides): Partial<GridConfig> {
return {
...(raw.columns != null
? { columns: clamp(raw.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX) }
: {}),
...(raw.gap != null ? { gap: clamp(raw.gap, 0, PHOTO_GRID_GAP_MAX) } : {}),
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
...(raw.layout != null
? {
layout: (VALID_LAYOUTS.includes(raw.layout)
? raw.layout
: 'masonry') as GridConfig['layout'],
}
: {}),
};
}
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
export function buildSubpageGrid(
raw?: RawGridOverrides,
): { grid: Partial<GridConfig> } | Record<string, never> {
if (!raw) return {};
return { grid: parseGridOverrides(raw) };
}
/**
* The two grids a subpage can override, as one spread-able object.
*
* `grid` sizes the photos inside its albums, `coverGrid` the album-cover tiles
* on the page itself. They were a single key until #523, where the admin field
* labelled "Album Cover Grid" turned out to retune every photo grid on the page
* as well. A gallery.yaml written before the split has no `coverGrid`, so the
* covers fall back to `grid` and such a page renders exactly as it did.
*/
function buildSubpageGrids(
grid?: RawGridOverrides,
coverGrid?: RawGridOverrides,
): { grid?: Partial<GridConfig>; coverGrid?: Partial<GridConfig> } {
const resolved = coverGrid ?? grid;
return {
...buildSubpageGrid(grid),
...(resolved ? { coverGrid: parseGridOverrides(resolved) } : {}),
};
}
/**
* Whether client proofing is on for a page.
*
* Precedence is subpage → global, the same shape `buildSubpageGrid` gives the
* grid: a subpage's own `proofing:` wins in either direction, and a page
* reached without one follows `proofing.enabled` from settings.yaml. `??`
* rather than `||` is load-bearing — `proofing: false` on a subpage has to
* override a global `true`, which `||` would discard.
*
* Photo essays deliberately do not use this: a published story is not an album
* handover, so it requires an explicit `proofing: true` and ignores the global
* default. That exception lives at its call site in app/[...path]/page.tsx.
*/
export function resolveProofing(
subpage: { proofing?: boolean } | undefined,
globalEnabled: boolean,
): boolean {
return subpage?.proofing ?? globalEnabled;
}
/**
* The recipient of the proofing dialog's "email to photographer" (#736):
* `proofing.email` when set, else the footer contact email. The first
* non-empty value, trimmed — an address saved as only whitespace is not
* configured. Undefined means neither is set, and the dialog hides the button
* rather than opening a compose with an empty To:.
*/
export function resolveProofingEmail(
proofingEmail?: string,
footerEmail?: string,
): string | undefined {
return (proofingEmail ?? '').trim() || (footerEmail ?? '').trim() || undefined;
}
/**
* EXPERIMENTAL: keep only nav links that are safe to render as header <a>
* tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are
* dropped with a warning rather than throwing — a bad external link should
* not take the site down.
*/
export function sanitizeNavLinks(
raw?: Array<{ label?: string; url?: string }>,
): Array<{ label: string; url: string }> {
if (!raw) return [];
const links: Array<{ label: string; url: string }> = [];
for (const entry of raw) {
const label = entry.label?.trim();
const url = entry.url?.trim();
if (!label || !url || !/^https?:\/\//i.test(url)) {
console.warn(
`[Folio] settings.yaml navLinks: dropping entry ${JSON.stringify(entry)} — ` +
'label and an http(s) url are required.',
);
continue;
}
links.push({ label, url });
}
return links;
}
/**
* The `legal:` block of settings.yaml, as the Impressum page renders it.
*
* `contactUrl` ends up in an `href`, so it gets the same rule as navLinks:
* anything that is not http(s) is dropped with a warning, which keeps a
* `javascript:` URL in hand-edited YAML off the page without taking it down.
* Empty strings become undefined so the page can test fields with `&&`.
*/
export function resolveLegal(raw?: Partial<LegalConfig>): LegalConfig {
const opt = (value?: string) => value?.trim() || undefined;
let contactUrl = opt(raw?.contactUrl);
if (contactUrl && !isHttpUrl(contactUrl)) {
console.warn(
`[Folio] settings.yaml legal.contactUrl: dropping ${JSON.stringify(contactUrl)} — ` +
'only http(s) URLs are allowed.',
);
contactUrl = undefined;
}
return {
enabled: raw?.enabled === true,
heading: opt(raw?.heading),
name: raw?.name || '',
address: raw?.address || '',
zipCity: raw?.zipCity || '',
country: raw?.country || '',
email: opt(raw?.email),
phone: opt(raw?.phone),
contactUrl,
contactLabel: opt(raw?.contactLabel),
taxId: opt(raw?.taxId),
vatId: opt(raw?.vatId),
extraInfo: opt(raw?.extraInfo),
};
}
/**
* The `contact:` block. `CONTACT_NOTIFY_URL` wins over `notifyUrl`, the same
* precedence SITE_PASSWORD has: an ntfy topic URL is as good as a password to
* anyone who wants to spam the owner's phone, so it may live outside the YAML.
* A non-http(s) URL is dropped with a warning, like `legal.contactUrl`.
*/
export function resolveContact(
raw?: SettingsYaml['contact'],
envNotifyUrl?: string,
): ContactConfig {
let notifyUrl = envNotifyUrl?.trim() || raw?.notifyUrl?.trim() || undefined;
if (notifyUrl && !isHttpUrl(notifyUrl)) {
console.warn(
`[Folio] contact.notifyUrl: dropping ${JSON.stringify(notifyUrl)} — only http(s) URLs are allowed.`,
);
notifyUrl = undefined;
}
const days = raw?.retentionDays;
return {
enabled: raw?.enabled === true,
notifyUrl,
retentionDays:
typeof days === 'number' && Number.isFinite(days)
? clamp(Math.round(days), 1, CONTACT_RETENTION_MAX)
: CONTACT_RETENTION_DEFAULT,
};
}
/** The parts of AppConfig that come from gallery.yaml. */
export interface GalleryDerivation {
albums: string[];
standaloneAlbums: string[];
subpages: SubpageConfig[];
nav: NavEntry[];
albumOverrides: Record<string, string>;
albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>;
albumHeroImages: Record<string, string>;
albumSortModes: Record<string, AlbumSortMode>;
albumManualOrders: Record<string, string[]>;
albumGrids: Record<string, Partial<GridConfig>>;
albumCoverPositions: Record<string, string>;
albumDownloads: Record<string, boolean>;
albumLocationPrecision: Record<string, LocationPrecision>;
}
/**
* Derive the gallery half of AppConfig, throwing on a structure that cannot be
* represented: a subpage with no name, a subpage with neither albums nor
* sections. A gallery with no albums and no subpages is NOT an error — an
* empty gallery still renders (title, hero-less homepage) so the owner can
* add albums later via /admin.
*
* 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> = {};
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
const albumGrids: Record<string, Partial<GridConfig>> = {};
const albumCoverPositions: Record<string, string> = {};
const albumDownloads: Record<string, boolean> = {};
const albumLocationPrecision: Record<string, LocationPrecision> = {};
/** Returns null for an entry whose album ID is not a UUID; callers drop it. */
function processAlbumEntry(
entry: string | Record<string, string | AlbumEntryObject>,
context: string,
inheritedLocation?: LocationPrecision,
): string | null {
if (typeof entry === 'string') {
if (inheritedLocation) albumLocationPrecision[entry] = inheritedLocation;
return validateUuid(entry, context);
}
const [uuid, value] = Object.entries(entry)[0];
const validatedUuid = validateUuid(uuid, context);
if (!validatedUuid) return null;
// The subpage's setting applies unless the album states its own below.
if (inheritedLocation) albumLocationPrecision[validatedUuid] = inheritedLocation;
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) {
const heroId = validateUuid(
value.heroImage,
`${context} heroImage for album ${validatedUuid}`,
);
// Dropped rather than defaulted: without one the album falls back to its
// Immich cover, which is a better answer than a request that 400s.
if (heroId) albumHeroImages[validatedUuid] = heroId;
}
// Throw rather than fall back to the default: a typo would otherwise be
// invisible — the album would just keep rendering in Immich order and the
// owner would be left wondering why the setting does nothing. Throwing
// also gives the admin PUT its 400 for free via the deriveGallery
// dry-run, and getConfigOrNull() degrades a hand-edited file to the setup
// screen rather than taking the site down. Name the album and the legal
// values so the log line is enough to fix it.
if (value.sort != null) {
if (!isAlbumSortMode(value.sort)) {
throw new Error(
`${context}: album ${validatedUuid} has an unknown sort "${value.sort}". ` +
`Valid values are: ${ALBUM_SORT_MODES.join(', ')}.`,
);
}
albumSortModes[validatedUuid] = value.sort;
}
// Kept regardless of the mode, so switching manual → newest → manual does
// not silently destroy a hand-curated order.
if (value.assetOrder?.length) {
albumManualOrders[validatedUuid] = value.assetOrder
.map((assetId, i) =>
validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`),
)
.filter((id): id is string => id !== null);
}
// EXPERIMENTAL: per-album grid override — reuses the subpage-grid
// normalisation so unknown layouts degrade identically.
if (value.grid) {
const built = buildSubpageGrid(value.grid);
if ('grid' in built) albumGrids[validatedUuid] = built.grid;
}
// EXPERIMENTAL: coverPosition lands in a style attribute, so only a
// strict CSS position grammar is allowed. Throw like `sort` does — a
// silent fallback would leave the owner wondering why nothing moves.
if (value.coverPosition != null) {
const pos = value.coverPosition.trim();
const keyword = /^(center|top|bottom|left|right)( (center|top|bottom|left|right))?$/;
const percent = /^\d{1,3}% \d{1,3}%$/;
if (!keyword.test(pos) && !percent.test(pos)) {
throw new Error(
`${context}: album ${validatedUuid} has an invalid coverPosition "${value.coverPosition}". ` +
`Use two percentages ("50% 25%") or CSS keywords ("top", "center bottom").`,
);
}
albumCoverPositions[validatedUuid] = pos;
}
// Opt-in per album, never global: a public portfolio must not start
// handing out full-resolution originals because one client gallery
// needed to (#475).
if (value.download === true) albumDownloads[validatedUuid] = true;
const location = parseLocation(value.location, `${context}: album ${validatedUuid} location`);
if (location) albumLocationPrecision[validatedUuid] = location;
}
return validatedUuid;
}
/** Album entries, with the ones whose ID is not a UUID dropped (#517). */
function processAlbumEntries(
entries: Array<string | Record<string, string | AlbumEntryObject>>,
context: string,
inheritedLocation?: LocationPrecision,
): string[] {
return entries
.map((entry) => processAlbumEntry(entry, context, inheritedLocation))
.filter((id): id is string => id !== null);
}
/**
* Narrow a raw `location:` value, or throw. Rejected rather than defaulted
* for the same reason as `sort`: a typo in a privacy setting that silently
* fell back to `exact` would publish the position it was meant to withhold.
*/
function parseLocation(raw: string | undefined, context: string): LocationPrecision | undefined {
if (raw == null) return undefined;
if (!isLocationPrecision(raw)) {
throw new Error(
`${context}: unknown location "${raw}". ` +
`Valid values are: ${LOCATION_PRECISIONS.join(', ')}.`,
);
}
return raw;
}
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
let subpages: SubpageConfig[] = [];
/** Menu order, subpages and `- page:` references interleaved (#722). */
const nav: NavEntry[] = [];
if (Array.isArray(gallery.subpages)) {
const pageRefs = new Set<string>();
const subpageEntries: GallerySubpageYaml[] = [];
for (const entry of gallery.subpages) {
if (isPageRef(entry)) {
const slug = entry.page.trim();
// Thrown like every other structure error: the admin PUT turns it into
// a 400, and a hand-edited file degrades to the setup screen.
if (!isValidSlug(slug)) {
throw new Error(
`gallery.yaml subpages: "page: ${entry.page}" is not a valid page slug ` +
`(letters, digits, "-" and "_" only).`,
);
}
if (pageRefs.has(slug)) {
throw new Error(`gallery.yaml subpages: page "${slug}" is listed more than once.`);
}
pageRefs.add(slug);
nav.push({ type: 'page', slug });
} else {
subpageEntries.push(entry);
// Placeholder, filled in with the derived slug below.
nav.push({ type: 'subpage', slug: '' });
}
}
subpages = subpageEntries.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: processAlbumEntries(
sec.albums,
`subpage "${sp.name}" section "${sec.title}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
),
}));
// flat union of all section albums
albumIds = sections!.flatMap((sec) => sec.albumIds);
// Also include any top-level albums (outside sections)
const topLevel = processAlbumEntries(
sp.albums ?? [],
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
);
albumIds = [...topLevel, ...albumIds];
} else {
const albums = sp.albums ?? [];
if (albums.length === 0) {
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
}
albumIds = processAlbumEntries(
albums,
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
);
}
return {
name: sp.name,
slug: slugify(sp.name),
title: sp.title,
subtitle: sp.subtitle,
albumIds,
sections,
password: sp.password,
proofing: sp.proofing,
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
...buildSubpageGrids(sp.grid, sp.coverGrid),
};
});
let next = 0;
for (const entry of nav) {
if (entry.type === 'subpage') entry.slug = subpages[next++].slug;
}
} else if (gallery.subpages) {
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
if (Array.isArray(value)) {
return {
name,
slug: slugify(name),
albumIds: processAlbumEntries(value, `subpage "${name}"`),
enabled: true,
};
}
const sp = value as SubpageObjectValue;
const albumEntries = sp.albums || [];
return {
name,
slug: slugify(name),
title: sp.title,
subtitle: sp.subtitle,
albumIds: processAlbumEntries(
albumEntries,
`subpage "${name}"`,
parseLocation(sp.location, `subpage "${name}" location`),
),
password: sp.password,
proofing: sp.proofing,
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
...buildSubpageGrids(sp.grid, sp.coverGrid),
};
});
}
if (!Array.isArray(gallery.subpages)) {
for (const sp of subpages) nav.push({ type: 'subpage', slug: sp.slug });
}
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
const standaloneAlbums = standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id));
// Every consumer that resolves a slug takes the first match — getAlbumBySlug,
// the sitemap, findPassword — so two subpages that slugify alike leave the
// second unreachable and its password / albums resolved from the first
// (#632). slugify() folds diacritics and punctuation, so distinct names
// collide easily: "Portfolio 2024" and "Portfolio-2024" both become
// "portfolio-2024".
const subpagesBySlug = new Map<string, string[]>();
for (const sp of subpages) {
const names = subpagesBySlug.get(sp.slug) ?? [];
names.push(sp.name);
subpagesBySlug.set(sp.slug, names);
}
for (const [slug, names] of subpagesBySlug) {
if (names.length > 1) {
throw new Error(
`Subpages ${names.map((n) => `"${n}"`).join(' and ')} all produce the slug "${slug}". ` +
`Rename one so their URLs do not collide.`,
);
}
}
// Same rule applies to albums that share a route (standalone albums among
// themselves, or one subpage's albums including its sections). Only a title
// override is known here without asking Immich — an album that keeps its
// Immich name can still collide, which is what the doctor's equivalent
// check, run against the live album list, is for.
const checkAlbumOverrideCollisions = (context: string, ids: string[]) => {
const named = ids
.map((id) => ({ id, name: albumOverrides[id] }))
.filter((a): a is { id: string; name: string } => !!a.name);
for (const { slug, albums } of findAlbumSlugCollisions(named)) {
throw new Error(
`${context}: albums titled ${albums.map((a) => `"${a.name}"`).join(' and ')} all produce ` +
`the slug "${slug}". Give one a different title override.`,
);
}
};
checkAlbumOverrideCollisions('gallery.yaml albums', standaloneAlbums);
for (const sp of subpages) {
checkAlbumOverrideCollisions(`subpage "${sp.name}"`, sp.albumIds);
}
return {
albums: allAlbumIds,
standaloneAlbums,
subpages,
nav,
albumOverrides,
albumDescriptions,
albumPasswords,
albumHeroImages,
albumSortModes,
albumManualOrders,
albumGrids,
albumCoverPositions,
albumDownloads,
albumLocationPrecision,
};
}
/** Whether content/about.md exists — the About page has nothing to show without it. */
function aboutContentExists(): boolean {
try {
return fs.existsSync(path.join(process.cwd(), 'content', 'about.md'));
} catch {
return false;
}
}
/**
* Everything deriveConfig() reads, as one string: the path and mtime of each
* file (or `-` when it is absent) and the parsed environment.
*/
function configInputs(): string {
const content = path.join(process.cwd(), 'content');
const files = [
installFilePath(),
path.join(content, 'gallery.yaml'),
path.join(content, 'settings.yaml'),
path.join(content, 'about.md'),
];
// throwIfNoEntry: a missing file is the normal case for install.json and
// about.md, and building an ENOENT error costs more than the stat.
// turbopackIgnore: `file` is not statically resolvable, so without the
// comment Turbopack traces the whole project into .next/standalone. Only
// the mtime is read here; the files themselves are loaded elsewhere.
const stamps = files.map(
(file) =>
`${file}@${fs.statSync(/* turbopackIgnore: true */ file, { throwIfNoEntry: false })?.mtimeMs ?? '-'}`,
);
return `${stamps.join('|')}|${JSON.stringify(env)}`;
}
/**
* The site configuration.
*
* Admin saves can land in a different worker or process than page rendering,
* so this cannot be a per-process singleton: freshness comes from the files'
* mtimes, re-checked on every call (a statSync each). What is cached is the
* derivation. A render and every image request call this many times over
* (`immich.config` is a getter), and rebuilding the whole config from the
* YAML each time cost more than the stats.
*
* The inputs are the ones loadYaml() and readInstallFile() already key their
* own caches on, so this is never staler than they are. Each caller still
* gets its own copy, exactly as before: nothing can mutate the cached one.
* A config that throws is not cached, so it throws again on the next call.
*/
export function getConfig(): AppConfig {
const inputs = configInputs();
if (derived?.inputs !== inputs) {
derived = { inputs, config: deriveConfig() };
}
return structuredClone(derived.config);
}
function deriveConfig(): AppConfig {
const { apiUrl, apiKey } = getInstallCredentials();
const authSecret = resolveAuthSecret();
// The API URL may already end with /api (a user-supplied env var or a
// wizard-installed value). Appending unconditionally produces a double
// suffix and breaks every Immich request.
const immichApiUrl = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl}/api`;
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
// Two separate faults, deliberately kept apart. Missing credentials mean
// nothing can reach Immich; a missing gallery.yaml only means the public site
// has nothing to list yet. Conflating them made a credentialed deployment
// without gallery.yaml report "Immich disconnected" and locked the admin
// panel out of the album pickers that would have created the file (#507).
const needsCredentials = !apiKey || !apiUrl;
const needsGallery = !gallery;
if (needsCredentials || needsGallery) {
// Return dummy config if gallery.yaml is missing
return {
immich: { apiUrl: immichApiUrl, apiKey },
authSecret,
albums: [],
standaloneAlbums: [],
subpages: [],
nav: [],
siteTitle: env.SITE_TITLE || 'Immich Folio',
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
lang: 'en',
// Nothing to protect yet, and a gate here would sit in front of the
// setup screen that is trying to tell the operator what is missing.
sitePassword: '',
seo: {
title: 'Setup Required',
description: 'Please configure Immich Folio',
titleTemplate: '%s | Setup Required',
license: '',
noIndex: true,
noFollow: true,
},
heroImages: [],
siteUrl: null,
siteUrlSource: 'none',
colorMode: 'dark',
exifOnHover: true,
exif: resolveExifDisplay(),
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
gridGapExplicit: false,
theme: resolveTheme(DEFAULT_PRESET),
footer: null,
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
contact: { enabled: false, retentionDays: CONTACT_RETENTION_DEFAULT },
privacy: { enabled: false },
map: false,
transitions: false,
scrollToTop: false,
analytics: true,
proofing: { enabled: false, allowMailto: false },
aboutEnabled: true,
albumOverrides: {},
albumDescriptions: {},
albumPasswords: {},
albumHeroImages: {},
albumSortModes: {},
albumManualOrders: {},
albumGrids: {},
albumCoverPositions: {},
albumDownloads: {},
albumLocationPrecision: {},
navLinks: [],
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,
needsCredentials,
needsGallery,
};
}
const theme = resolveTheme(settings.theme);
const {
albums: allAlbumIds,
standaloneAlbums,
subpages,
nav,
albumOverrides,
albumDescriptions,
albumPasswords,
albumHeroImages,
albumSortModes,
albumManualOrders,
albumGrids,
albumCoverPositions,
albumDownloads,
albumLocationPrecision,
} = deriveGallery(gallery);
// Last-resort title in the site's own language rather than a hardcoded
// English "Gallery" (#696).
const defaultTitle = env.SITE_TITLE || getDictionary(resolveLocale(settings.lang)).common.gallery;
const siteSeoTitle = settings.seo?.title || settings.title || defaultTitle;
return {
immich: { apiUrl: immichApiUrl, apiKey },
authSecret,
albums: allAlbumIds,
standaloneAlbums,
subpages,
nav,
siteTitle: settings.title ?? defaultTitle,
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
lang: settings.lang ?? 'en',
// Env wins, so a deployment can rotate the site password without touching
// settings.yaml — the same precedence the Immich credentials use.
sitePassword: env.SITE_PASSWORD ?? settings.sitePassword ?? '',
seo: {
title: siteSeoTitle,
description:
settings.seo?.description ||
settings.subtitle ||
env.SITE_SUBTITLE ||
'A curated photography portfolio',
titleTemplate: settings.seo?.titleTemplate || `%s | ${siteSeoTitle}`,
noIndex: settings.seo?.noIndex === true,
noFollow: settings.seo?.noFollow === true,
license: settings.seo?.license?.trim() || '',
},
heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
.map((id) => validateUuid(id, 'gallery.yaml hero'))
.filter((id): id is string => id !== null)
: [],
...(() => {
const site = resolveSiteUrl(settings.url, env.SITE_URL);
return { siteUrl: site.url, siteUrlSource: site.source };
})(),
colorMode: resolveColorMode(settings.mode),
exifOnHover: settings.exifOnHover !== false,
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
grid: {
columns:
settings.grid?.columns != null
? clamp(settings.grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX)
: 3,
gap: settings.grid?.gap != null ? clamp(settings.grid.gap, 0, PHOTO_GRID_GAP_MAX) : 12,
aspectRatio: settings.grid?.aspectRatio ?? '1',
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
? settings.grid!.layout
: 'masonry') as GridConfig['layout'],
},
gridGapExplicit: settings.grid?.gap != null,
theme,
footer: settings.footer
? {
name: settings.footer.name,
instagram: settings.footer.instagram,
email: settings.footer.email,
website: settings.footer.website,
}
: null,
legal: resolveLegal(settings.legal),
contact: resolveContact(settings.contact, env.CONTACT_NOTIFY_URL),
privacy: { enabled: settings.privacy?.enabled !== false },
map: settings.map === true,
transitions: settings.transitions !== false,
scrollToTop: settings.scrollToTop !== false,
analytics: settings.analytics !== false,
proofing: {
enabled: settings.proofing?.enabled !== false,
allowMailto: settings.proofing?.allowMailto !== false,
// `proofing.email`, else the footer contact email (#736); absent hides
// the dialog's email button.
email: resolveProofingEmail(settings.proofing?.email, settings.footer?.email),
},
protection: settings.protection,
watermark: settings.watermark,
// The setting alone is not enough: about.md does not exist until someone
// writes it, so a fresh install put a nav link in front of an empty page
// (#518). `enabled: false` still hides it even when the file is there.
aboutEnabled: settings.about?.enabled !== false && aboutContentExists(),
albumOverrides,
albumDescriptions,
albumPasswords,
albumHeroImages,
albumSortModes,
albumManualOrders,
albumGrids,
albumCoverPositions,
albumDownloads,
albumLocationPrecision,
navLinks: sanitizeNavLinks(settings.navLinks),
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,
needsCredentials: false,
needsGallery: false,
};
}