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 { 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 } | 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; } /** * 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 * 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 { 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; albumDescriptions: Record; albumPasswords: Record; albumHeroImages: Record; albumSortModes: Record; albumManualOrders: Record; albumGrids: Record>; albumCoverPositions: Record; albumDownloads: Record; /** Album zoom overrides on the standalone entries; subpages carry their own. */ standaloneAlbumZoom: 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 = {}; /** * Per-album zoom overrides (#467), one map per route: the standalone entries * here, each subpage's (sections included) on the subpage. An album listed on * two pages can be zoomable on one and not the other; a single map would let * whichever entry came last decide for both. */ const standaloneAlbumZoom: 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, zoomSink?: Record, ): 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; // Both values mean something: `false` switches zoom off here even where // the subpage or the site has it on (#467). Anything else is ignored. // Written to the route this entry is on. Listed twice on one route (two // sections of a page), the cautious answer wins: `false` stays `false`. if (zoomSink && typeof value.zoom === 'boolean') { zoomSink[validatedUuid] = zoomSink[validatedUuid] !== false && value.zoom; } 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, zoomSink?: Record, ): string[] { return entries .map((entry) => processAlbumEntry(entry, context, inheritedLocation, zoomSink)) .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', undefined, standaloneAlbumZoom, ); let subpages: SubpageConfig[] = []; /** Menu order, subpages and `- page:` references interleaved (#722). */ const nav: NavEntry[] = []; if (Array.isArray(gallery.subpages)) { const pageRefs = new Set(); 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`); } // This page's own album zoom overrides, sections included (#467). const albumZoom: Record = {}; // 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`), albumZoom, ), })); // 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`), albumZoom, ); 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`), albumZoom, ); } return { name: sp.name, slug: slugify(sp.name), title: sp.title, subtitle: sp.subtitle, albumIds, sections, password: sp.password, proofing: sp.proofing, zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined, ...(Object.keys(albumZoom).length ? { albumZoom } : {}), 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 || []; const albumZoom: Record = {}; return { name, slug: slugify(name), title: sp.title, subtitle: sp.subtitle, albumIds: processAlbumEntries( albumEntries, `subpage "${name}"`, parseLocation(sp.location, `subpage "${name}" location`), albumZoom, ), password: sp.password, proofing: sp.proofing, zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined, ...(Object.keys(albumZoom).length ? { albumZoom } : {}), 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(); 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, standaloneAlbumZoom, 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('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: [], 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, zoom: false, albumOverrides: {}, albumDescriptions: {}, albumPasswords: {}, albumHeroImages: {}, albumSortModes: {}, albumManualOrders: {}, albumGrids: {}, albumCoverPositions: {}, albumDownloads: {}, standaloneAlbumZoom: {}, 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, standaloneAlbumZoom, 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(), // Opt-in: zoom serves originals, which a portfolio must not start doing // because an upgrade added the feature (#467). zoom: settings.zoom === true, albumOverrides, albumDescriptions, albumPasswords, albumHeroImages, albumSortModes, albumManualOrders, albumGrids, albumCoverPositions, albumDownloads, standaloneAlbumZoom, 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, }; }