Files
immich-folio/lib/config/index.ts
Ralksta c9036fdc94 security: add Trivy container scan to Docker publish workflow (#399)
* feat(admin): add backup manager UI with 1-click restore

* feat(admin): collapse older backup entries by default

* fix(theme): isolate admin layout, instant DOM theme updates, and add Light/Dark mode toggle

* feat(admin): replace text controls for Photo Frame and Hero Style with visual preview cards

* fix(admin): define missing CSS rules for button system (.btn, .preset-btn)

* feat(admin): redesign theme settings with segmented mode toggle, accent color swatches, and custom iOS-style toggle cards

* feat(admin): upgrade all settings tabs with visual grid previews, Google SEO snippet preview, and toggle cards

* feat(admin): overhaul Pages Builder UX with slide-over drawer, card hover badges, real-time search, and floating save bar

* fix(admin): remove leftover dangling syntax snippet in PageBuilder.tsx

* fix(admin): add position: relative to subpage-tile so hover overlay is contained inside the tile

* style(admin): replace all emojis with clean Leica-style vector SVG icons

* fix(types): extend IconProps with SVGProps to allow style prop on Leica SVG icons

* feat(admin): add subpage live-preview, privacy analytics, and asset protection settings

* fix(typecheck): fix AlbumEntry property access in subpage drawer preview mode

* fix(types): fix ImmichAlbumInfo property names thumbnailAssetId and albumName in PageBuilder.tsx

* fix(types): add protection and watermark optional properties to AppConfig interface

* feat(settings): add toggle for disabling analytics tracking (defaults to enabled)

* feat(ux): show status banner when analytics tracking is disabled + add live sync indicator

* feat(watermark): render customizable watermark overlay on Lightbox images + rename section to Portfolio Features & Modules

* fix(types): import type LightboxWatermark in PhotoGrid.tsx

* fix(types): destructure watermark prop in PhotoGrid function signature

* feat(seo): add configurable subpage title template and metadata descriptions

* feat(seo): add generateMetadata to about page for title template support

* feat(proofing): add client proofing, favorites selection, compact URL bitmasking, and export modal

* feat(essay): add Photo Essay storytelling mode with RSC markdown parser and fullbleed/pair layouts

* feat(subpages): add page enabled toggle to deactivate subpages without deleting them

* feat(admin): add visual essay block builder for photo essay storytelling pages

* security: add Trivy container scan to Docker publish workflow

Scans the built image for CRITICAL/HIGH CVEs on every release and
uploads results to the GitHub Security tab. Non-blocking for now
(exit-code 0) so releases aren't gated on findings. Also documents
the scan and existing Dependabot coverage in the README.

---------

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

389 lines
13 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,
proofing: sp.proofing,
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
...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}"`)),
enabled: true,
};
}
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,
proofing: sp.proofing,
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
...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',
titleTemplate: '%s | Setup Required',
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,
analytics: true,
proofing: { enabled: false, allowMailto: 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);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || '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: 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,
},
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,
analytics: settings.analytics !== false,
proofing: {
enabled: settings.proofing?.enabled !== false,
allowMailto: settings.proofing?.allowMailto !== false,
},
protection: settings.protection,
watermark: settings.watermark,
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,
};
}