See CHANGELOG.md for the full v0.16.0 entry. --------- 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>
716 lines
26 KiB
TypeScript
716 lines
26 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 } 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,
|
|
} 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.
|
|
*
|
|
* `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;
|
|
}
|
|
|
|
/**
|
|
* 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 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>;
|
|
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[] = [];
|
|
|
|
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])];
|
|
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,
|
|
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<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: [],
|
|
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 != 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: {
|
|
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,
|
|
};
|
|
}
|