import fs from 'node:fs'; import path from 'node:path'; import { env } from '../env'; import { resolveAuthSecret } from '../secret'; import { resolveSiteUrl } from '../siteUrl'; import { getInstallCredentials } 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, resolveExifDisplay, resolveColorMode, AppConfig, AlbumEntryObject, 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; } } interface RawGridOverrides { columns?: number; gap?: number; aspectRatio?: string; layout?: string; } /** Converts raw YAML grid overrides into a typed partial GridConfig. */ function parseGridOverrides(raw: RawGridOverrides): Partial { return { ...(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'], } : {}), }; } /** Converts raw YAML grid overrides into a typed partial GridConfig. */ export function buildSubpageGrid( raw?: RawGridOverrides, ): { grid: Partial } | Record { 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; coverGrid?: Partial } { 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; } /** * EXPERIMENTAL: keep only nav links that are safe to render as header * 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 parts of AppConfig that come from gallery.yaml. */ export interface GalleryDerivation { albums: string[]; standaloneAlbums: string[]; subpages: SubpageConfig[]; albumOverrides: Record; albumDescriptions: Record; albumPasswords: Record; albumHeroImages: Record; albumSortModes: Record; albumManualOrders: Record; albumGrids: Record>; albumCoverPositions: Record; albumDownloads: Record; albumLocationPrecision: Record; } /** * 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 = {}; const albumDescriptions: Record = {}; const albumPasswords: Record = {}; const albumHeroImages: Record = {}; const albumSortModes: Record = {}; const albumManualOrders: Record = {}; const albumGrids: Record> = {}; const albumCoverPositions: Record = {}; const albumDownloads: Record = {}; const albumLocationPrecision: Record = {}; /** Returns null for an entry whose album ID is not a UUID; callers drop it. */ function processAlbumEntry( entry: string | Record, 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>, 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[] = []; 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: 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), }; }); } 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), }; }); } 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, 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; } } 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, 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('gallery.yaml'); const settings = loadYaml('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: [], 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: '' }, 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, albumOverrides, albumDescriptions, albumPasswords, albumHeroImages, albumSortModes, albumManualOrders, albumGrids, albumCoverPositions, albumDownloads, albumLocationPrecision, } = deriveGallery(gallery); const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery'; return { immich: { apiUrl: immichApiUrl, apiKey }, authSecret, albums: allAlbumIds, standaloneAlbums, subpages, siteTitle: settings.title ?? env.SITE_TITLE, 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 ?? 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'], }, 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: { 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, scrollToTop: settings.scrollToTop !== false, analytics: settings.analytics !== false, proofing: { enabled: settings.proofing?.enabled !== false, allowMailto: settings.proofing?.allowMailto !== false, }, 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, }; }