release: v0.16.0

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>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -10,8 +10,13 @@ import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumS
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,
@@ -65,11 +70,22 @@ interface RawGridOverrides {
layout?: string;
}
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
/**
* 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: raw.columns } : {}),
...(raw.gap != null ? { gap: raw.gap } : {}),
...(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
? {
@@ -418,10 +434,53 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
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: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
standaloneAlbums,
subpages,
albumOverrides,
albumDescriptions,
@@ -587,8 +646,11 @@ export function getConfig(): AppConfig {
exifOnHover: settings.exifOnHover !== false,
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
grid: {
columns: settings.grid?.columns ?? 3,
gap: settings.grid?.gap ?? 12,
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

View File

@@ -159,10 +159,19 @@ export function buildCoverGridVars(
return vars;
}
function clamp(value: number, min: number, max: number): number {
/**
* `unknown` rather than `number`: this also guards the site-wide and
* per-subpage/album grid columns and gap (lib/config/index.ts), which a
* hand-edited gallery.yaml or settings.yaml can hand a string, `null`, or
* anything else. `typeof value !== 'number'` catches those before
* `Number.isFinite` gets a chance to — it would otherwise coerce nothing and
* just report `false` for a non-number, which happens to be what we want here
* too, but checking the type first makes that not an accident.
*/
export function clamp(value: unknown, min: number, max: number): number {
// NaN from a malformed YAML value would survive Math.min/Math.max, and
// `repeat(NaN, 1fr)` is an invalid declaration that drops the whole rule.
if (!Number.isFinite(value)) return min;
if (typeof value !== 'number' || !Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.round(value)));
}
@@ -539,6 +548,36 @@ export function albumSlug(name: string, id: string): string {
return slugify(name) || id;
}
/**
* Groups of albums whose slug collides, among a set that will all be
* reachable under one URL prefix — the standalone albums, or one subpage's
* albums (top-level and sectioned together, since they share a route).
*
* Every consumer that resolves a slug — getAlbumBySlug, the sitemap, the page
* builder — takes the first match, so an unflagged collision makes the second
* album unreachable and has its settings (password, sort, grid…) resolved
* from the first instead (#632).
*
* An empty slug (a name with no letters or digits at all) is excluded:
* albumSlug() falls back to the album id for that case, which is unique by
* construction, so it can never actually collide.
*/
export function findAlbumSlugCollisions(
albums: Array<{ id: string; name: string }>,
): Array<{ slug: string; albums: Array<{ id: string; name: string }> }> {
const bySlug = new Map<string, Array<{ id: string; name: string }>>();
for (const album of albums) {
const slug = slugify(album.name);
if (!slug) continue;
const group = bySlug.get(slug) ?? [];
group.push(album);
bySlug.set(slug, group);
}
return Array.from(bySlug.entries())
.filter(([, group]) => group.length > 1)
.map(([slug, group]) => ({ slug, albums: group }));
}
/**
* Normalize a slug that came in from a URL before comparing it to a stored one.
*

View File

@@ -0,0 +1,201 @@
/**
* Runtime shape check for settings.yaml, used before the admin panel writes it.
*
* `PUT /api/admin/settings` used to cast the request body and write it. The
* gallery route has had a net since a save there "used to report Saved
* successfully and then take the public site down"; this is the same net for
* settings (#599).
*
* ── What it checks, and what it deliberately does not ──────────────
*
* **Types and structure, not values.** A string where an object belongs, an
* array where a record belongs, a number where a switch belongs — those are the
* shapes that make a resolver throw, and they are what this rejects.
*
* Value narrowing belongs in the resolvers, because they are the only place
* that also sees hand-edited YAML — this check only ever sees what the panel
* sends. Several already do it: `resolveColorMode` falls back to `dark` for
* anything it does not know, `resolveWatermarkOpacity` accepts both a fraction
* and a percentage because a documented workaround produced the latter (#508),
* and the album sort and location resolvers throw with a message naming the
* offending value. Repeating those rules here would mean two places to keep in
* step, and a stricter one would reject configurations that work today.
*
* `grid.columns` and `theme.radius` got the same treatment in #633: the
* site-wide and per-subpage/album grid derivation (`lib/config/index.ts`)
* clamps `columns` and `gap` to the bounds the admin inputs already share,
* and `resolveTheme` (`lib/config/theme.ts`) coerces `radius`, `accent`,
* `grain` and `headerDot` the same way `resolveColorMode` does — falling back
* 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.
*
* **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
* rejecting it would lock an operator out of their own settings through the
* very panel that writes the file. Hence `looseObject` throughout.
*/
import { z } from 'zod';
const str = z.string().optional();
const bool = z.boolean().optional();
const num = z.number().optional();
const gridLike = z.looseObject({
columns: num,
gap: num,
aspectRatio: str,
layout: str,
});
export const settingsSchema = z.looseObject({
title: str,
subtitle: str,
url: str,
lang: str,
seo: z
.looseObject({
title: str,
description: str,
titleTemplate: str,
noIndex: bool,
noFollow: bool,
license: str,
})
.optional(),
// Narrowed by resolveColorMode, which falls back to 'dark'.
mode: str,
exifOnHover: bool,
exif: z
.looseObject({
camera: bool,
settings: bool,
location: bool,
caption: bool,
})
.optional(),
map: bool,
transitions: bool,
scrollToTop: bool,
analytics: bool,
proofing: z.looseObject({ enabled: bool, allowMailto: bool }).optional(),
// Checked separately in validateSettings — see themeObjectSchema.
theme: z.unknown().optional(),
grid: gridLike.optional(),
footer: z.looseObject({ name: str, instagram: str, email: str, website: str }).optional(),
legal: z
.looseObject({
enabled: bool,
name: str,
address: str,
zipCity: str,
country: str,
email: str,
phone: str,
taxId: str,
vatId: str,
extraInfo: str,
})
.optional(),
navLinks: z.array(z.looseObject({ label: str, url: str })).optional(),
protection: z.looseObject({ disableRightClick: bool, disableImageDrag: bool }).optional(),
sitePassword: str,
watermark: z
.looseObject({
enabled: bool,
text: str,
// Not bounded here: resolveWatermarkOpacity reads anything above 1 as a
// percentage, which is what keeps the #508 workaround working.
opacity: num,
position: str,
})
.optional(),
about: z.looseObject({ enabled: bool }).optional(),
});
/**
* The object form of `theme`, checked on its own rather than as a union branch.
*
* `theme` accepts either a preset name or an object overriding fields on top of
* one. Expressed as `z.union([z.string(), …])`, a mistake deep inside the
* object reports at `theme` — zod cannot say which branch was meant, so it
* blames the field. Dispatching on the type first is what turns that back into
* `theme.fonts.heading`, which is the difference between the panel marking the
* offending input and shrugging at the whole section.
*/
const themeObjectSchema = z.looseObject({
preset: str,
accent: str,
fonts: z.looseObject({ heading: str, body: str, caption: str }).optional(),
radius: num,
photoFrame: str,
grain: bool,
headerDot: bool,
heroStyle: str,
});
export interface SettingsFieldError {
/** Dotted path to the offending field, e.g. `theme.radius`. */
field: string;
message: string;
}
export type SettingsValidation = { ok: true } | { ok: false; errors: SettingsFieldError[] };
/**
* Validate a settings payload before it is written.
*
* Errors carry the field path so the panel can put the message next to the
* input that caused it, rather than showing one opaque failure for a file with
* forty fields in it.
*/
export function validateSettings(input: unknown): SettingsValidation {
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
return {
ok: false,
errors: [{ field: '', message: 'Settings must be an object.' }],
};
}
const errors: SettingsFieldError[] = [];
const result = settingsSchema.safeParse(input);
if (!result.success) {
errors.push(
...result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
);
}
const theme = (input as { theme?: unknown }).theme;
if (theme !== undefined && typeof theme !== 'string') {
const themeResult = themeObjectSchema.safeParse(theme);
if (!themeResult.success) {
errors.push(
...themeResult.error.issues.map((issue) => ({
field: ['theme', ...issue.path].join('.'),
message: issue.message,
})),
);
}
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

View File

@@ -96,6 +96,33 @@ export const VALID_LAYOUTS = [
'justified', // EXPERIMENTAL: row-based layout with equal heights
];
/**
* Corner radius reaches CSS as `${radius}px` (app/layout.tsx: `--radius-sm`)
* and `${radius * 1.5}px` (`--radius-md`), so a value that is not actually a
* number — `radius: "8px"` in hand-edited YAML, say — does not fail loudly,
* it becomes `8pxpx` and `NaNpx` (#633). photoFrame and heroStyle, on either
* side of this field, are already checked against an allowlist; this is the
* same treatment for the one numeric scalar in ThemeConfig.
*/
const THEME_RADIUS_MAX = 64; // Presets top out at 16; generous but not unbounded.
function resolveRadius(raw: unknown, fallback: number): number {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return fallback;
return Math.min(THEME_RADIUS_MAX, Math.max(0, raw));
}
/** `--accent` and `--accent-dim` (app/layout.tsx) take this verbatim. */
function resolveAccent(raw: unknown, fallback: string): string {
return typeof raw === 'string' && raw.trim() ? raw : fallback;
}
/** `String(theme.grain)`/`String(theme.headerDot)` become a `data-*` attribute
* either way, but a non-boolean is a sign the value was never meant for this
* field at all — a typo'd key one level up, say — so it falls back rather
* than being coerced into something that reads as intentional. */
function resolveThemeBoolean(raw: unknown, fallback: boolean): boolean {
return typeof raw === 'boolean' ? raw : fallback;
}
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
if (!raw) return { ...THEME_PRESETS[DEFAULT_PRESET] };
@@ -119,18 +146,18 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
return {
preset: baseName,
accent: raw.accent ?? base.accent,
accent: resolveAccent(raw.accent, base.accent),
fonts: {
heading: raw.fonts?.heading ?? base.fonts.heading,
body: raw.fonts?.body ?? base.fonts.body,
caption: raw.fonts?.caption ?? base.fonts.caption,
},
radius: raw.radius ?? base.radius,
radius: resolveRadius(raw.radius, base.radius),
photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '')
? (raw.photoFrame as ThemeConfig['photoFrame'])
: base.photoFrame,
grain: raw.grain ?? base.grain,
headerDot: raw.headerDot ?? base.headerDot,
grain: resolveThemeBoolean(raw.grain, base.grain),
headerDot: resolveThemeBoolean(raw.headerDot, base.headerDot),
heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '')
? (raw.heroStyle as ThemeConfig['heroStyle'])
: base.heroStyle,