release: v0.19.0

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -3,7 +3,7 @@ import path from 'node:path';
import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { resolveSiteUrl } from '../siteUrl';
import { getInstallCredentials } from '../install';
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';
@@ -30,14 +30,23 @@ import {
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;
}
/**
@@ -150,6 +159,20 @@ export function resolveProofing(
return subpage?.proofing ?? globalEnabled;
}
/**
* The recipient of the proofing dialog's "email to photographer" (#736):
* `proofing.email` when set, else the footer contact email. The first
* non-empty value, trimmed — an address saved as only whitespace is not
* configured. Undefined means neither is set, and the dialog hides the button
* rather than opening a compose with an empty To:.
*/
export function resolveProofingEmail(
proofingEmail?: string,
footerEmail?: string,
): string | undefined {
return (proofingEmail ?? '').trim() || (footerEmail ?? '').trim() || undefined;
}
/**
* EXPERIMENTAL: keep only nav links that are safe to render as header <a>
* tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are
@@ -244,6 +267,7 @@ export interface GalleryDerivation {
albums: string[];
standaloneAlbums: string[];
subpages: SubpageConfig[];
nav: NavEntry[];
albumOverrides: Record<string, string>;
albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>;
@@ -408,9 +432,35 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
let subpages: SubpageConfig[] = [];
/** Menu order, subpages and `- page:` references interleaved (#722). */
const nav: NavEntry[] = [];
if (Array.isArray(gallery.subpages)) {
subpages = gallery.subpages.map((sp) => {
const pageRefs = new Set<string>();
const subpageEntries: GallerySubpageYaml[] = [];
for (const entry of gallery.subpages) {
if (isPageRef(entry)) {
const slug = entry.page.trim();
// Thrown like every other structure error: the admin PUT turns it into
// a 400, and a hand-edited file degrades to the setup screen.
if (!isValidSlug(slug)) {
throw new Error(
`gallery.yaml subpages: "page: ${entry.page}" is not a valid page slug ` +
`(letters, digits, "-" and "_" only).`,
);
}
if (pageRefs.has(slug)) {
throw new Error(`gallery.yaml subpages: page "${slug}" is listed more than once.`);
}
pageRefs.add(slug);
nav.push({ type: 'page', slug });
} else {
subpageEntries.push(entry);
// Placeholder, filled in with the derived slug below.
nav.push({ type: 'subpage', slug: '' });
}
}
subpages = subpageEntries.map((sp) => {
if (!sp.name) {
throw new Error(`Subpage "(unnamed)" must have a name`);
}
@@ -467,6 +517,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
...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)) {
@@ -500,6 +554,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
});
}
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));
@@ -550,6 +608,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albums: allAlbumIds,
standaloneAlbums,
subpages,
nav,
albumOverrides,
albumDescriptions,
albumPasswords,
@@ -572,11 +631,54 @@ function aboutContentExists(): boolean {
}
}
/**
* 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 {
// 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 inputs = configInputs();
if (derived?.inputs !== inputs) {
derived = { inputs, config: deriveConfig() };
}
return structuredClone(derived.config);
}
function deriveConfig(): AppConfig {
const { apiUrl, apiKey } = getInstallCredentials();
const authSecret = resolveAuthSecret();
@@ -604,6 +706,7 @@ export function getConfig(): AppConfig {
albums: [],
standaloneAlbums: [],
subpages: [],
nav: [],
siteTitle: env.SITE_TITLE || 'Immich Folio',
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
lang: 'en',
@@ -665,6 +768,7 @@ export function getConfig(): AppConfig {
albums: allAlbumIds,
standaloneAlbums,
subpages,
nav,
albumOverrides,
albumDescriptions,
albumPasswords,
@@ -677,7 +781,10 @@ export function getConfig(): AppConfig {
albumLocationPrecision,
} = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || '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 },
@@ -685,7 +792,8 @@ export function getConfig(): AppConfig {
albums: allAlbumIds,
standaloneAlbums,
subpages,
siteTitle: settings.title ?? env.SITE_TITLE,
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
@@ -746,6 +854,9 @@ export function getConfig(): AppConfig {
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,

View File

@@ -1,5 +1,6 @@
import type { AlbumSortMode } from '../albumSort';
import type { LocationPrecision } from '../mapPrecision';
import type { NavEntry } from '../pages';
export interface SubpageSectionConfig {
title: string;
@@ -217,6 +218,11 @@ export interface AppConfig {
albums: string[];
standaloneAlbums: string[];
subpages: SubpageConfig[];
/**
* The header menu in gallery.yaml order: every subpage (hidden and disabled
* ones included — the menu filters) and every `- page:` reference (#722).
*/
nav: NavEntry[];
siteTitle: string;
siteSubtitle: string;
lang: string;
@@ -264,6 +270,13 @@ export interface AppConfig {
proofing: {
enabled: boolean;
allowMailto: boolean;
/**
* Effective "email to photographer" recipient: `proofing.email` from
* settings when set, otherwise the footer contact email. Undefined means
* neither is configured, and the proofing dialog hides the email action
* rather than opening a compose with an empty To: (#736).
*/
email?: string;
};
aboutEnabled: boolean;
albumOverrides: Record<string, string>;
@@ -352,43 +365,63 @@ export interface AlbumEntryObject {
location?: string;
}
/**
* A content page placed in the menu (#722): `- page: <slug>` among the
* subpages. The page itself lives in content/pages/<slug>.md.
*/
export interface PageRefYaml {
page: string;
}
/** Whether a raw `subpages:` list entry is a page reference rather than a subpage. */
export function isPageRef(entry: unknown): entry is PageRefYaml {
return (
typeof entry === 'object' &&
entry !== null &&
'page' in entry &&
typeof (entry as { page: unknown }).page === 'string'
);
}
export interface GalleryYaml {
hero?: string | string[];
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
subpages?:
| Record<string, string[] | Array<string | Record<string, string>>>
| Array<{
name: string;
title?: string;
subtitle?: string;
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
sections?: Array<{
title: string;
description?: string;
albums: Array<string | Record<string, string | AlbumEntryObject>>;
}>;
password?: string;
proofing?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
hidden?: boolean;
/** Map precision inherited by every album here (#469). */
location?: string;
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
/** Album covers only; falls back to `grid` when unset (#523). */
coverGrid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
}>;
| Array<GallerySubpageYaml | PageRefYaml>;
}
export interface GallerySubpageYaml {
name: string;
title?: string;
subtitle?: string;
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
sections?: Array<{
title: string;
description?: string;
albums: Array<string | Record<string, string | AlbumEntryObject>>;
}>;
password?: string;
proofing?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
hidden?: boolean;
/** Map precision inherited by every album here (#469). */
location?: string;
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
/** Album covers only; falls back to `grid` when unset (#523). */
coverGrid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
}
export interface SettingsYaml {
@@ -417,6 +450,8 @@ export interface SettingsYaml {
proofing?: {
enabled?: boolean;
allowMailto?: boolean;
/** Overrides the footer contact email as the proofing recipient. */
email?: string;
};
theme?:
| string
@@ -644,3 +679,44 @@ export function normalizeSlug(slug: string): string {
}
return decoded.normalize('NFC');
}
/**
* The albums the public site shows: the standalone ones and those of every
* subpage that is not offline. `albums` is the allowlist for loading an album
* and still holds the albums of a subpage taken offline with `enabled: false`
* (its configuration is kept), so it does not answer "is this published".
* Anything that lists albums or serves their photos outside a page route asks
* this instead.
*/
export function onlineAlbumIds(config: {
standaloneAlbums?: readonly string[];
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean }>;
}): Set<string> {
return new Set([
...(config.standaloneAlbums ?? []),
...config.subpages.filter((sp) => sp.enabled !== false).flatMap((sp) => sp.albumIds),
]);
}
/** A subpage that appears in listings: online and not `hidden`. */
export function isListedSubpage(sp: { enabled?: boolean; hidden?: boolean }): boolean {
return sp.enabled !== false && sp.hidden !== true;
}
/**
* The albums a public listing may name: the standalone ones and those of
* every subpage that is online and not `hidden`. Narrower than
* {@link onlineAlbumIds} — a hidden subpage is reachable by direct link only,
* so its photos are still served (publishedAssets asks `onlineAlbumIds`), but
* an overview such as the map must not advertise them. An album that a listed
* subpage or the standalone list also carries stays.
*/
export function listedAlbumIds(config: {
standaloneAlbums?: readonly string[];
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean; hidden?: boolean }>;
}): Set<string> {
return new Set([
...(config.standaloneAlbums ?? []),
...config.subpages.filter(isListedSubpage).flatMap((sp) => sp.albumIds),
]);
}

View File

@@ -0,0 +1,71 @@
/**
* Values in settings.yaml that the site would silently ignore (QA A-14).
*
* `validateSettings` (settingsSchema.ts) checks types and structure and
* deliberately leaves values to the resolvers, which clamp or fall back so
* hand-edited YAML never takes the site down. For the fields the panel offers
* an input for, that meant a save of `accent: rot` or nine columns in a 1–6
* field was reported as "Saved" while the site showed something else.
*
* This checks exactly those fields — the site URL, the accent, the grid
* columns and gap — with the resolvers' own rules and bounds, so it can never
* be stricter than what renders. Kept apart from settingsSchema.ts so the
* settings form can run it as you type without bundling zod; the save route
* runs it before writing, and the doctor over settings.yaml as it is.
*/
import { normaliseSiteUrl } from '../siteUrl';
import { isAccentHex } from './theme';
import { PHOTO_GRID_COLUMNS_MAX, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_GAP_MAX } from './schema';
import type { SettingsFieldError } from './settingsSchema';
function inRange(value: unknown, min: number, max: number): boolean {
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
}
/** One error per offending field; an empty string counts as unset, as in the resolvers. */
export function validateSettingValues(input: unknown): SettingsFieldError[] {
if (!input || typeof input !== 'object') return [];
const s = input as {
url?: unknown;
theme?: unknown;
grid?: { columns?: unknown; gap?: unknown } | null;
};
const errors: SettingsFieldError[] = [];
// resolveSiteUrl: an unusable value falls back to SITE_URL, or to nothing,
// and the sitemap, feed and structured data go out without the address.
if (typeof s.url === 'string' && s.url.trim() !== '' && !normaliseSiteUrl(s.url)) {
errors.push({
field: 'url',
message: 'Must be a full http(s) address, e.g. https://photos.example.com.',
});
}
// resolveTheme: anything but hex falls back to the preset's accent.
const accent =
s.theme && typeof s.theme === 'object' ? (s.theme as { accent?: unknown }).accent : undefined;
if (typeof accent === 'string' && accent.trim() !== '' && !isAccentHex(accent)) {
errors.push({ field: 'theme.accent', message: 'Must be a hex colour such as #e60012.' });
}
// getConfig clamps both into these bounds (lib/config/index.ts).
const grid = s.grid && typeof s.grid === 'object' ? s.grid : null;
if (
grid?.columns != null &&
!inRange(grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX)
) {
errors.push({
field: 'grid.columns',
message: `Must be a whole number from ${PHOTO_GRID_COLUMNS_MIN} to ${PHOTO_GRID_COLUMNS_MAX}.`,
});
}
if (grid?.gap != null && !inRange(grid.gap, 0, PHOTO_GRID_GAP_MAX)) {
errors.push({
field: 'grid.gap',
message: `Must be a whole number from 0 to ${PHOTO_GRID_GAP_MAX}.`,
});
}
return errors;
}

View File

@@ -29,6 +29,10 @@
* to the preset's own value for anything that is not the right type, which
* covers hand-edited YAML as well as a save from this panel.
*
* **Values the panel offers an input for** are the exception, checked with the
* resolvers' own rules in `settingValues.ts`: the site URL, the accent and the
* grid columns and gap, which a save used to accept and the site then ignored.
*
* **Unknown keys pass through.** There is no `schemaVersion` and no migration
* code, so configurations older and newer than this build are both in the wild.
* A key this version has never heard of is not evidence of a mistake, and
@@ -84,7 +88,7 @@ export const settingsSchema = z.looseObject({
scrollToTop: bool,
analytics: bool,
proofing: z.looseObject({ enabled: bool, allowMailto: bool }).optional(),
proofing: z.looseObject({ enabled: bool, allowMailto: bool, email: str }).optional(),
// Checked separately in validateSettings — see themeObjectSchema.
theme: z.unknown().optional(),

View File

@@ -130,6 +130,11 @@ function resolveRadius(raw: unknown, fallback: number): number {
*/
const ACCENT_HEX = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
/** Whether `resolveTheme` keeps an accent rather than falling back to the preset's. */
export function isAccentHex(value: string): boolean {
return ACCENT_HEX.test(value.trim());
}
/** Trim, case-fold and widen to `#rrggbb`; refuse anything that is not hex. */
function resolveAccent(raw: unknown, fallback: string): string {
if (typeof raw !== 'string') return fallback;