Release v0.18.0 — merges `dev` into `main`. ## Thanks - [@lancetm714](https://github.com/lancetm714) for the accent-contrast fix (#680 → #724). - [@RichKidsDev](https://github.com/RichKidsDev) for reporting the outdated Impressum (#697). ### Security - **Journal drafts and password-protected entries no longer render publicly as a subpage essay** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9), medium). A subpage's `essayFile` read the journal entry straight from disk, skipping the draft and password rules `/journal/<slug>` applies. It now resolves through the same rules: a draft reads as missing for visitors, and a protected entry shows its password gate, sharing the `lb_auth_journal_<slug>` cookie. - **Immich descriptions are escaped in generated subpage essays** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm), low). The essay built from a subpage's albums (`layout: essay` without markdown) put each asset's Immich description into the page as raw HTML. Captions are now HTML-escaped, and they go through `assetCaption()`, so `exif.caption: false` is respected there as well. Tracked in [#689](https://github.com/ralksta/immich-folio/issues/689). - **Admin drafts no longer keep a typed password, and the contact form's email check runs in linear time** ([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's session drafts wrote a password as typed to `sessionStorage`; a draft holding a plaintext password is now not kept (the unsaved-changes guard still warns). The email pattern could backtrack polynomially on a crafted domain; its labels now exclude the dot and the length is checked first. Both found by CodeQL before release. ### Added - **A built-in contact form with an inbox in the admin** ([#703](https://github.com/ralksta/immich-folio/pull/703), closes [#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact` needs neither SMTP nor a form service: each message is one file in `content/messages/` (mode `0600`), read under the new _Messages_ tab, where _Reply by email_ opens the owner's mail client with the message quoted. Messages older than `contact.retentionDays` (default 90) are deleted. An optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`, and since [#709](https://github.com/ralksta/immich-folio/pull/709) it carries a fixed "new message" line and nothing about the sender — no name, no address, no text. Spam is kept out without a captcha: an off-screen honeypot, a minimum fill time, three submissions a minute per IP and a cap of 500 stored messages. With the Impressum on and no `legal.contactUrl`, the Impressum links the form as its second contact channel. A new doctor check warns when the form is on but nobody is notified. - **A privacy policy page, next to a list of what the site processes** ([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy` renders `content/privacy.md`, edited under Settings → Legal, and the footer links it once the file has text; `privacy.enabled: false` hides it. Folio writes no legal text. Beside the editor it lists what _this_ installation does, read off the configuration — the map, statistics, contact form, password cookies, CDN — and marks every entry that involves a third party. _Insert headings_ adds the matching section headings and nothing else. The doctor warns while the Impressum is on and there is no policy. - **French, Spanish, Italian and Dutch interface translations** ([#681](https://github.com/ralksta/immich-folio/pull/681)), selected with `settings.yaml: lang` as before. The admin panel stays English. - **CDN mode for photos and videos** ([#681](https://github.com/ralksta/immich-folio/pull/681)). With `CDN_URL` set, image and video URLs point at a pull CDN whose origin is this server, so repeat views reach neither the server nor Immich. A site password switches the mode off, since a CDN would hand out cached photos without checking the unlock cookie. The CDN origin is added to the CSP, and a doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See `docs/deployment.md#cdn-mode`. - **A reworked admin panel** ([#712](https://github.com/ralksta/immich-folio/pull/712), [#713](https://github.com/ralksta/immich-folio/pull/713), [#714](https://github.com/ralksta/immich-folio/pull/714), [#716](https://github.com/ralksta/immich-folio/pull/716), [#717](https://github.com/ralksta/immich-folio/pull/717), [#718](https://github.com/ralksta/immich-folio/pull/718), [#719](https://github.com/ralksta/immich-folio/pull/719), [#721](https://github.com/ralksta/immich-folio/pull/721)): - **A sidebar instead of the header row**, grouped by task, with an unread count on Messages and a dot on Diagnostics while Immich is down. It collapses to an icon rail, with names shown on hover and focus, and remembers that per browser. - **`/admin` is an overview**: what needs attention (unread messages, doctor warnings), published pages, journal entries and recent views. - **The site's own visual language** — the preset's fonts, hairline borders, the square marker — and one page header on every screen. The accent is kept for the current place and the primary action; switches are now pills reading ON or OFF, in green rather than the accent, so Studio Modern's red no longer makes an enabled setting look like an error. - **The page builder is a structure list and a panel.** Hero, subpages and standalone albums sit in one list on the left, subpages drag-sorted in place; the selected entry is edited on the right instead of in an overlay. - **Dialogs only where a change is permanent.** Deleting a message or a journal entry, and resetting the favicon (which used to delete the file without asking), go through one confirm dialog in the admin's own look. Removals in the page builder, which only touch unsaved state, happen at once and offer _Undo_. No `window.confirm()` is left. - **Feature switches are grouped by meaning** as one-line rows under Settings → General; page transitions and scroll-to-top moved to Theme → Look & motion. No setting keys changed. ### Changed - **Fonts and the Leaflet stylesheet are served from the site itself** ([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page used to link `fonts.googleapis.com`, which sends each visitor's IP address to Google. The server now fetches a theme's Google fonts once, rewrites the stylesheet and caches the files in `content/.fonts/`; custom fonts in `theme.fonts` keep working. If Google cannot be reached before a font is cached, the page falls back to the theme's system fonts. `leaflet.css` comes from the npm package instead of unpkg. The CSP drops `fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`. - **Email addresses are kept out of the served HTML** ([#705](https://github.com/ralksta/immich-folio/pull/705)). The Impressum and footer render the address only after hydration, from an encoded form, so harvesters reading raw HTML find nothing. Without JavaScript the Impressum shows `name [at] example [dot] de`. This defeats bulk harvesting, not a person with a browser. - **The Impressum cites § 5 DDG and links its contacts** ([#698](https://github.com/ralksta/immich-folio/pull/698), reported in [#697](https://github.com/ralksta/immich-folio/issues/697) by [@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by the DDG in May 2024; `legal.heading` replaces the line for other laws (e.g. § 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a contact form as the second channel, so a phone number stays optional. Email, phone and contact URL are now links, and the "Created with the Impressum generator by eRecht24" line, shown on every site whether true or not, is gone. Settings → Legal gains fields for heading, contact URL, VAT ID and tax number, and a doctor check flags missing details. - **Missing pages answer `404`** ([#704](https://github.com/ralksta/immich-folio/pull/704)). Every missing page used to answer `200`, because streaming had started before `notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while switched off, and `/journal/<slug>` without an entry, now return a real 404 with the same not-found page. Album and subpage slugs still depend on Immich and stay soft 404s. - **Passwords set in the admin are stored as scrypt hashes** ([#706](https://github.com/ralksta/immich-folio/pull/706), closes [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage, album, journal-entry and site passwords were written as typed. The save routes now hash them; a stored hash reads _Protected_ with _Change_ and _Remove_ and is never put into an input. Visitors who unlocked a page stay signed in across unrelated saves. Existing plaintext passwords are hashed the next time their file is saved in the admin. - **One notification surface in the admin** ([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces every `alert()`: errors stay until dismissed, successes clear themselves, and screen readers announce both. ### Fixed - **Map tiles load again** ([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO started answering every tile with an "API KEY REQUIRED" image, leaving `/map` and journal map blocks as a grey grid. Tiles now come from OpenStreetMap; the dark look is a CSS filter, so the map now follows the colour mode. - **The page builder no longer drops a subpage's `proofing:` flag on save** ([#711](https://github.com/ralksta/immich-folio/pull/711)). A page switched off for proofing by hand reverted after any unrelated edit. - **Unsaved edits in Settings and About survive switching admin tabs, and a backup restore is no longer overwritten by the next save** ([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that were open during a restore now reload and offer unsaved edits back as a conflict. Also: "Restore my changes" after a conflict found nothing, removing an album left its drawer showing the next album, and the journal saved (rotating a backup) without changes. - **Accent-backed buttons stay readable with a light or malformed accent** ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes [#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed by [@lancetm714](https://github.com/lancetm714). A white accent made the proofing dialog's _Download selected (.zip)_ button white on white. `theme.accent` is now accepted as hex only and normalised to `#rrggbb`, anything else falls back to the preset, and the proofing dialog and filter pills use the mode-aware `--on-accent` text colour from #688. - **Every preset's accent is visible in both colour modes** ([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had one accent for both modes; minimal's dark mode drew black buttons on black. Presets now carry a light and a dark accent, and text on accent-filled buttons picks black or white for contrast. A custom accent stays the owner's choice in both modes. - **Visitor-side inconsistencies** ([#685](https://github.com/ralksta/immich-folio/pull/685)): after visiting `/impressum`, every subpage header turned centred and uppercase; back links announced "Back to Back to …"; the hero EXIF chip ignored `exif.location`/`camera`/`settings`; videos in journal entries opened as stills; the setup screen named a non-existent `.env.example`. - **An essay's lightbox walks only the photos the story shows** ([#686](https://github.com/ralksta/immich-folio/pull/686)), not every photo of the subpage's albums, and the proofing bar in essays shows the selection count instead of a filter button that did nothing. - **The page panel scrolls with the mouse wheel** ([#720](https://github.com/ralksta/immich-folio/pull/720)). - **The journal editor's top bar wraps on phones** ([#723](https://github.com/ralksta/immich-folio/pull/723), closes [#675](https://github.com/ralksta/immich-folio/issues/675)), so the title stays editable and Save stays on screen. - **Every admin form field has a name for assistive technology** ([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two inputs had a linked label; 47 are linked now, and clicking a label focuses its field. ### Internal - **`SettingsEditor` and `PageBuilder` are split up** ([#710](https://github.com/ralksta/immich-folio/pull/710), [#711](https://github.com/ralksta/immich-folio/pull/711)). The 2,200-line settings editor is a shell with one file per section; the page builder's YAML handling and edits are pure functions under test. Both were checked side by side against `dev` for identical output. - **`useAdminFetch()`** ([#707](https://github.com/ralksta/immich-folio/pull/707)) gives Analytics, Messages, Backups and the journal list one loading and error pattern. - README brought up to v0.17.0 ([#678](https://github.com/ralksta/immich-folio/pull/678), [#679](https://github.com/ralksta/immich-folio/pull/679)). ### Upgrade notes **`content/` gains two directories the app writes to:** `content/.fonts/` for the cached fonts and `content/messages/` for contact-form messages. Both are created on demand, so a writable `content/` is enough, as before. **The server needs outbound HTTPS to Google once per font** to fill the font cache. Until then pages fall back to the theme's system fonts. Visitors' browsers no longer contact Google. **A custom CSP or reverse-proxy allowlist** must now allow `tile.openstreetmap.org` for images instead of `*.basemaps.cartocdn.com`, and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or `unpkg.com`. The built-in CSP is updated. **`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`). Anything else, including a value without `#`, falls back to the preset's accent. **Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal entries** are hashed the next time that file is saved in the admin; one save clears the doctor's warning. **New, optional settings:** `contact.*` (the form is off by default), `privacy.enabled`, `legal.heading`, `legal.contactUrl`, `legal.contactLabel`, and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See `content/settings.yaml.example` and `.env.local.example`. In the example file, `vatId` and `taxId` had swapped sample values; check yours if you copied them. No `IMAGE_CACHE_VERSION` bump is needed this time. --------- 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>
647 lines
23 KiB
TypeScript
647 lines
23 KiB
TypeScript
import type { AlbumSortMode } from '../albumSort';
|
||
import type { LocationPrecision } from '../mapPrecision';
|
||
|
||
export interface SubpageSectionConfig {
|
||
title: string;
|
||
slug: string; // auto-derived from title, used as anchor
|
||
description?: string;
|
||
albumIds: string[];
|
||
}
|
||
|
||
export interface SubpageConfig {
|
||
name: string;
|
||
slug: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
albumIds: string[]; // flat list (all albums, incl. from sections)
|
||
sections?: SubpageSectionConfig[];
|
||
password?: string;
|
||
/** Photo grid for the albums on this page: global < subpage < album. */
|
||
grid?: Partial<GridConfig>;
|
||
/**
|
||
* The album-cover tiles on this page. Falls back to `grid` when unset, which
|
||
* is what a pre-#523 gallery.yaml renders with (#523).
|
||
*/
|
||
coverGrid?: Partial<GridConfig>;
|
||
proofing?: boolean;
|
||
essayFile?: string;
|
||
essayText?: string;
|
||
enabled?: boolean;
|
||
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||
hidden?: boolean;
|
||
}
|
||
|
||
export interface SubpageObjectValue {
|
||
title?: string;
|
||
subtitle?: string;
|
||
password?: string;
|
||
grid?: Partial<GridConfig>;
|
||
/** Album-cover tiles only; falls back to `grid` when unset (#523). */
|
||
coverGrid?: Partial<GridConfig>;
|
||
proofing?: boolean;
|
||
essayFile?: string;
|
||
essayText?: string;
|
||
enabled?: boolean;
|
||
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||
hidden?: boolean;
|
||
/** Map precision for every album here that does not set its own (#469). */
|
||
location?: string;
|
||
// Both album lists reuse AlbumEntryObject rather than inlining the shape:
|
||
// the inline copies had already drifted (they never gained `heroImage`), and
|
||
// this path is live for map-style subpages.
|
||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||
sections?: Array<{
|
||
title: string;
|
||
description?: string;
|
||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||
}>;
|
||
}
|
||
|
||
/** EXPERIMENTAL: external link shown in the header navigation. */
|
||
export interface NavLinkConfig {
|
||
label: string;
|
||
url: string;
|
||
}
|
||
|
||
export interface FooterConfig {
|
||
name?: string;
|
||
instagram?: string;
|
||
email?: string;
|
||
website?: string;
|
||
}
|
||
|
||
/**
|
||
* Whether a URL may go into an `href` taken from settings: http(s) only, so a
|
||
* `javascript:` or `data:` URL in hand-edited YAML never reaches a link.
|
||
*/
|
||
export function isHttpUrl(url: string): boolean {
|
||
return /^https?:\/\//i.test(url.trim());
|
||
}
|
||
|
||
export interface LegalConfig {
|
||
enabled: boolean;
|
||
/** Replaces the statute line under the title ("Angaben gemäß § 5 DDG"). */
|
||
heading?: string;
|
||
name: string;
|
||
address: string;
|
||
zipCity: string;
|
||
country: string;
|
||
email?: string;
|
||
phone?: string;
|
||
/** Second contact channel next to email, e.g. a contact form. http(s) only. */
|
||
contactUrl?: string;
|
||
contactLabel?: string;
|
||
taxId?: string;
|
||
vatId?: string;
|
||
extraInfo?: string;
|
||
}
|
||
|
||
/** The built-in contact form (`/contact`, #702). */
|
||
export interface ContactConfig {
|
||
enabled: boolean;
|
||
/**
|
||
* Where a "new message" notification is POSTed, e.g. an ntfy topic URL.
|
||
* `CONTACT_NOTIFY_URL` overrides the settings.yaml value. http(s) only.
|
||
*/
|
||
notifyUrl?: string;
|
||
/** Messages older than this are deleted. */
|
||
retentionDays: number;
|
||
}
|
||
|
||
export const CONTACT_RETENTION_DEFAULT = 90;
|
||
export const CONTACT_RETENTION_MAX = 365;
|
||
|
||
export interface ThemeConfig {
|
||
preset: string;
|
||
accent: string;
|
||
/**
|
||
* The accent for one colour mode, where a preset's single `accent` fails
|
||
* contrast against that mode's background (Minimal's black on its black dark
|
||
* mode, say). Only presets set these, and resolveTheme keeps them only while
|
||
* the owner uses the preset's own accent. See accentForMode().
|
||
*/
|
||
accentDark?: string;
|
||
accentLight?: string;
|
||
fonts: { heading: string; body: string; caption: string };
|
||
radius: number;
|
||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
||
grain: boolean;
|
||
headerDot: boolean;
|
||
heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic' | 'cover';
|
||
}
|
||
|
||
export interface GridConfig {
|
||
columns: number;
|
||
gap: number;
|
||
aspectRatio: string;
|
||
layout:
|
||
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
|
||
}
|
||
|
||
/**
|
||
* Bounds for the photo grid, shared by the site-wide and per-subpage inputs so
|
||
* the two forms cannot offer different ranges for the same setting.
|
||
*/
|
||
export const PHOTO_GRID_COLUMNS_MIN = 1;
|
||
export const PHOTO_GRID_COLUMNS_MAX = 6;
|
||
export const PHOTO_GRID_GAP_MAX = 48;
|
||
|
||
/**
|
||
* Bounds for the album-cover grid on a subpage. Shared with the admin inputs so
|
||
* the form and the renderer cannot drift apart.
|
||
*/
|
||
export const COVER_GRID_COLUMNS_MIN = 1;
|
||
export const COVER_GRID_COLUMNS_MAX = 6;
|
||
export const COVER_GRID_GAP_MAX = 80;
|
||
|
||
/** Tablet widths show at most two covers side by side, like the photo grids. */
|
||
export const COVER_GRID_TABLET_COLUMNS_MAX = 2;
|
||
|
||
/**
|
||
* The CSS custom properties that size the album-cover grid on a subpage.
|
||
*
|
||
* The overrides come from the subpage's own `coverGrid`, which sizes the cover
|
||
* tiles and nothing else — it used to be the same `grid` key the photos read,
|
||
* so a cover gap silently retuned every photo grid on the page (#523).
|
||
*
|
||
* `columns` still falls back to the global `settings.yaml` count, so a page
|
||
* that states nothing tiles its covers like its photos. `gap` deliberately does
|
||
* not: each theme preset picks the cover spacing as part of its look (1px
|
||
* monograph, 20px studio-modern), so the variable is only emitted when a
|
||
* subpage sets `gap` explicitly — otherwise the preset's own `--subpage-gap`
|
||
* stands.
|
||
*
|
||
* `--subpage-columns-tablet` is computed here rather than in CSS because
|
||
* `repeat()` does not reliably accept a `min()` expression.
|
||
*/
|
||
export function buildCoverGridVars(
|
||
overrides: Partial<GridConfig> | undefined,
|
||
globalColumns: number,
|
||
): Record<string, string | number> {
|
||
// A hand-edited `columns: 0` would emit `repeat(0, 1fr)` and collapse the
|
||
// grid, so the count is clamped rather than trusted.
|
||
const columns = clamp(
|
||
overrides?.columns ?? globalColumns,
|
||
COVER_GRID_COLUMNS_MIN,
|
||
COVER_GRID_COLUMNS_MAX,
|
||
);
|
||
const vars: Record<string, string | number> = {
|
||
'--subpage-columns': columns,
|
||
'--subpage-columns-tablet': Math.min(columns, COVER_GRID_TABLET_COLUMNS_MAX),
|
||
};
|
||
if (overrides?.gap != null) {
|
||
vars['--subpage-gap'] = `${clamp(overrides.gap, 0, COVER_GRID_GAP_MAX)}px`;
|
||
}
|
||
return vars;
|
||
}
|
||
|
||
/**
|
||
* `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 (typeof value !== 'number' || !Number.isFinite(value)) return min;
|
||
return Math.min(max, Math.max(min, Math.round(value)));
|
||
}
|
||
|
||
export interface AppConfig {
|
||
immich: { apiUrl: string; apiKey: string };
|
||
authSecret: string;
|
||
albums: string[];
|
||
standaloneAlbums: string[];
|
||
subpages: SubpageConfig[];
|
||
siteTitle: string;
|
||
siteSubtitle: string;
|
||
lang: string;
|
||
seo: {
|
||
title: string;
|
||
description: string;
|
||
titleTemplate: string;
|
||
noIndex: boolean;
|
||
noFollow: boolean;
|
||
/** Emitted as `license` in JSON-LD when set. */
|
||
license: string;
|
||
};
|
||
/**
|
||
* The site's own absolute URL, or null when nobody configured one. Sitemap,
|
||
* feed and JSON-LD need it and have no request context to derive it from.
|
||
*/
|
||
siteUrl: string | null;
|
||
/** Where `siteUrl` came from, so the admin panel can name it (#472). */
|
||
siteUrlSource: 'env' | 'settings' | 'none';
|
||
heroImages: string[];
|
||
/** Colour mode a first-time visitor lands on; their own choice overrides it. */
|
||
colorMode: ColorMode;
|
||
exifOnHover: boolean;
|
||
/** Which EXIF groups the grid hover, the lightbox panel and the album header may show. */
|
||
exif: ExifDisplayConfig;
|
||
grid: GridConfig;
|
||
/**
|
||
* Whether `grid.gap` was set in settings.yaml. The resolved `grid.gap` cannot
|
||
* say — it carries the fallback either way — and each theme preset picks its
|
||
* own photo spacing as part of its look, so the CSS variable is only emitted
|
||
* when someone actually chose a value (#513).
|
||
*/
|
||
gridGapExplicit: boolean;
|
||
theme: ThemeConfig;
|
||
footer: FooterConfig | null;
|
||
legal: LegalConfig;
|
||
contact: ContactConfig;
|
||
/** /privacy, shown while this is on and content/privacy.md has text (#699). */
|
||
privacy: { enabled: boolean };
|
||
map: boolean;
|
||
transitions: boolean;
|
||
/** Floating back-to-top arrow in the frontend. */
|
||
scrollToTop: boolean;
|
||
analytics: boolean;
|
||
proofing: {
|
||
enabled: boolean;
|
||
allowMailto: boolean;
|
||
};
|
||
aboutEnabled: boolean;
|
||
albumOverrides: Record<string, string>;
|
||
albumDescriptions: Record<string, string>;
|
||
albumPasswords: Record<string, string>;
|
||
albumHeroImages: Record<string, string>;
|
||
/** Per-album sort mode. Absent means `immich` — the album's own order. */
|
||
albumSortModes: Record<string, AlbumSortMode>;
|
||
/** Pinned asset UUIDs per album for `sort: manual`, in display order. */
|
||
albumManualOrders: Record<string, string[]>;
|
||
/** EXPERIMENTAL: per-album grid overrides, merged over the subpage/global grid. */
|
||
albumGrids: Record<string, Partial<GridConfig>>;
|
||
/** EXPERIMENTAL: per-album focal point for cover crops (CSS object-position). */
|
||
albumCoverPositions: Record<string, string>;
|
||
/** Albums whose originals may be downloaded. Absent means no (#475). */
|
||
albumDownloads: Record<string, boolean>;
|
||
/** Per-album map precision. Absent means `exact` (#469). */
|
||
albumLocationPrecision: Record<string, LocationPrecision>;
|
||
/** EXPERIMENTAL: external links appended to the header navigation. */
|
||
navLinks: NavLinkConfig[];
|
||
cacheTtl: number;
|
||
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
|
||
staleMaxAge: number;
|
||
/** Response-wait budget for Immich API calls. Does not cap image/video body transfer. */
|
||
immichTimeoutMs: number;
|
||
rateLimitRpm: number;
|
||
trustedProxyHops: number;
|
||
/**
|
||
* True while the app is not fully installed — either credentials or
|
||
* `gallery.yaml` are missing. Kept as the union of the two flags below for
|
||
* callers that only care whether the setup screen is due.
|
||
*/
|
||
needsSetup?: boolean;
|
||
/**
|
||
* No Immich URL or API key. Nothing can talk to Immich, and the setup wizard
|
||
* is the only way forward.
|
||
*/
|
||
needsCredentials?: boolean;
|
||
/**
|
||
* Credentials are present but `content/gallery.yaml` is not. The public site
|
||
* has nothing to show, yet Immich is perfectly reachable — the admin panel
|
||
* must stay usable so the file can be created from there.
|
||
*/
|
||
needsGallery?: boolean;
|
||
/**
|
||
* Password for the whole public site, or `''` when the site is open.
|
||
* Plaintext or `scrypt:salt:hash`, same as every other password in the app.
|
||
*/
|
||
sitePassword: string;
|
||
protection?: {
|
||
disableRightClick?: boolean;
|
||
disableImageDrag?: boolean;
|
||
};
|
||
watermark?: {
|
||
enabled?: boolean;
|
||
text?: string;
|
||
opacity?: number;
|
||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||
};
|
||
}
|
||
|
||
export interface AlbumEntryObject {
|
||
/** Optional: an entry may carry only a sort mode and no title override. */
|
||
title?: string;
|
||
description?: string;
|
||
password?: string;
|
||
heroImage?: string;
|
||
/**
|
||
* Raw YAML shape — narrowed to `AlbumSortMode` in deriveGallery(), which is
|
||
* where an unknown value has to be rejected with a message a human can act on.
|
||
*/
|
||
sort?: string;
|
||
/** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */
|
||
assetOrder?: string[];
|
||
/** EXPERIMENTAL: per-album grid override, merged over the subpage/global grid */
|
||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
|
||
coverPosition?: string;
|
||
/** Offer the original file for download from the lightbox (#475). Off by default. */
|
||
download?: boolean;
|
||
/**
|
||
* How precisely this album's photographs may be placed on the map:
|
||
* `exact` (default), `city`, `country` or `hidden`. Narrowed to
|
||
* `LocationPrecision` in deriveGallery(), where a typo has to be rejected.
|
||
*/
|
||
location?: 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;
|
||
};
|
||
}>;
|
||
}
|
||
|
||
export interface SettingsYaml {
|
||
title?: string;
|
||
subtitle?: string;
|
||
/** Absolute site URL, e.g. https://folio.example. `SITE_URL` overrides it. */
|
||
url?: string;
|
||
lang?: string;
|
||
seo?: {
|
||
title?: string;
|
||
description?: string;
|
||
titleTemplate?: string;
|
||
noIndex?: boolean;
|
||
noFollow?: boolean;
|
||
/** Licence URL or identifier for structured data, e.g. a CC link (#472). */
|
||
license?: string;
|
||
};
|
||
/** 'dark' (default), 'light' or 'auto' — the visitor's starting colour mode. */
|
||
mode?: ColorMode;
|
||
exifOnHover?: boolean;
|
||
exif?: ExifDisplayYaml;
|
||
map?: boolean;
|
||
transitions?: boolean;
|
||
scrollToTop?: boolean;
|
||
analytics?: boolean;
|
||
proofing?: {
|
||
enabled?: boolean;
|
||
allowMailto?: boolean;
|
||
};
|
||
theme?:
|
||
| string
|
||
| {
|
||
preset?: string;
|
||
accent?: string;
|
||
fonts?: { heading?: string; body?: string; caption?: string };
|
||
radius?: number;
|
||
photoFrame?: string;
|
||
grain?: boolean;
|
||
headerDot?: boolean;
|
||
heroStyle?: string;
|
||
};
|
||
grid?: {
|
||
columns?: number;
|
||
gap?: number;
|
||
aspectRatio?: string;
|
||
layout?: string;
|
||
};
|
||
footer?: FooterConfig;
|
||
legal?: Partial<LegalConfig>;
|
||
contact?: { enabled?: boolean; notifyUrl?: string; retentionDays?: number };
|
||
privacy?: { enabled?: boolean };
|
||
/** EXPERIMENTAL: external links appended to the header navigation. */
|
||
navLinks?: Array<{ label?: string; url?: string }>;
|
||
protection?: {
|
||
disableRightClick?: boolean;
|
||
disableImageDrag?: boolean;
|
||
};
|
||
/** Puts the whole public site behind one password. `SITE_PASSWORD` overrides it. */
|
||
sitePassword?: string;
|
||
watermark?: {
|
||
enabled?: boolean;
|
||
text?: string;
|
||
opacity?: number;
|
||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||
};
|
||
about?: { enabled?: boolean };
|
||
}
|
||
|
||
/**
|
||
* Which EXIF groups a site publishes, as written in settings.yaml.
|
||
*
|
||
* Grouped rather than field-by-field on purpose: a list of individual field
|
||
* names becomes a small language of its own — it needs validation, an error
|
||
* path for typos, and an admin control per field — for a choice people make in
|
||
* groups anyway ("no locations", "no descriptions") (#506).
|
||
*/
|
||
/**
|
||
* The colour mode a visitor sees before they touch the toggle. `auto` follows
|
||
* the operating system. A visitor's own choice is kept in localStorage and
|
||
* always wins — this only decides the starting point (#512).
|
||
*/
|
||
export type ColorMode = 'light' | 'dark' | 'auto';
|
||
|
||
export const COLOR_MODES: ColorMode[] = ['dark', 'light', 'auto'];
|
||
|
||
export function resolveColorMode(raw?: string): ColorMode {
|
||
return (COLOR_MODES as string[]).includes(raw ?? '') ? (raw as ColorMode) : 'dark';
|
||
}
|
||
|
||
export interface ExifDisplayYaml {
|
||
/** Camera body, lens, focal length. */
|
||
camera?: boolean;
|
||
/** Aperture, shutter speed, ISO. */
|
||
settings?: boolean;
|
||
/** City and country. */
|
||
location?: boolean;
|
||
/** The Immich asset description. */
|
||
caption?: boolean;
|
||
}
|
||
|
||
/** The resolved form: every group decided, plus whether the grid hovers at all. */
|
||
export interface ExifDisplayConfig extends Required<ExifDisplayYaml> {
|
||
/** Whether the photo grid shows its summary overlay on hover. */
|
||
onHover: boolean;
|
||
}
|
||
|
||
/**
|
||
* Merge the `exif:` block over the older `exifOnHover` switch.
|
||
*
|
||
* `exifOnHover: false` used to mean "no technical EXIF anywhere" — it gated the
|
||
* grid overlay *and* every technical field in the lightbox panel — while the
|
||
* Immich description was served regardless, as editorial caption. That left the
|
||
* one field holding private notes as the only one nobody could switch off
|
||
* (#506). The description is now a group like any other, and the old switch
|
||
* survives as the default the explicit groups are merged over, so both existing
|
||
* spellings keep their current meaning.
|
||
*/
|
||
export function resolveExifDisplay(
|
||
raw?: ExifDisplayYaml,
|
||
exifOnHover?: boolean,
|
||
): ExifDisplayConfig {
|
||
const onHover = exifOnHover !== false;
|
||
// What `exifOnHover: false` implied for the technical groups.
|
||
const technical = onHover;
|
||
|
||
return {
|
||
camera: raw?.camera ?? technical,
|
||
settings: raw?.settings ?? technical,
|
||
location: raw?.location ?? technical,
|
||
// Captions were never covered by the old switch, so their default does not
|
||
// follow it: a site that only ever set `exifOnHover: false` keeps showing
|
||
// them until it says otherwise.
|
||
caption: raw?.caption ?? true,
|
||
onHover,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Whether the lightbox info panel has anything left to show. When it does not,
|
||
* the `i` key and its button are withdrawn rather than opening an empty panel.
|
||
*/
|
||
export function hasExifPanelContent(exif: ExifDisplayConfig): boolean {
|
||
return exif.camera || exif.settings || exif.location || exif.caption;
|
||
}
|
||
|
||
/** Opacity used when `watermark.opacity` is unset. Matches the documented example. */
|
||
export const WATERMARK_DEFAULT_OPACITY = 0.5;
|
||
|
||
/**
|
||
* Normalise `watermark.opacity` to a CSS opacity (0–1).
|
||
*
|
||
* The documented unit is a fraction (`opacity: 0.5`), and the admin slider
|
||
* writes one — but the lightbox used to divide by 100 regardless, so a
|
||
* configured 0.9 rendered as 0.009 and was invisible. People worked around that
|
||
* by writing `90`, which the admin panel then displayed as "9000%" (#508).
|
||
*
|
||
* Both readings are therefore accepted, and the ambiguity is smaller than it
|
||
* looks: a fraction never exceeds 1, and a percentage below 1 would be an
|
||
* invisible watermark nobody configures on purpose. Anything above 1 is read as
|
||
* a percentage, so those workarounds keep working; the admin panel writes the
|
||
* fraction back on the next save.
|
||
*/
|
||
export function resolveWatermarkOpacity(raw?: number): number {
|
||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return WATERMARK_DEFAULT_OPACITY;
|
||
const fraction = raw > 1 ? raw / 100 : raw;
|
||
return Math.min(1, Math.max(0, fraction));
|
||
}
|
||
|
||
/**
|
||
* URL slug for a display name.
|
||
*
|
||
* NFD + combining-mark removal folds Latin diacritics ("Café" -> "cafe"), but
|
||
* everything else keeps its letters: the separator class is \p{L}\p{N}, not
|
||
* [a-z0-9]. The ASCII-only version stripped CJK, Hangul and Cyrillic names down
|
||
* to an empty slug, which turned their links into "/" (#522). NFC at the end
|
||
* recomposes what NFD split — Hangul syllables decompose into jamo, and only
|
||
* the composed form matches what a browser sends back in the URL.
|
||
*
|
||
* Can still return '' for a name made purely of symbols or emoji; callers that
|
||
* need a routable slug must supply a fallback (see albumSlug).
|
||
*/
|
||
export function slugify(name: string): string {
|
||
return name
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.normalize('NFC');
|
||
}
|
||
|
||
/**
|
||
* Routable slug for an album. Falls back to the album id when the name has no
|
||
* letters or digits at all, so an emoji-only album is still reachable — and so
|
||
* two of them do not collide on the same empty slug.
|
||
*/
|
||
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.
|
||
*
|
||
* Two things can differ even when the slug is "the same":
|
||
*
|
||
* - Percent-encoding. A non-ASCII path segment reaches the route handler
|
||
* still encoded ("%E5%AE%B6..."), so comparing it raw against the stored
|
||
* slug never matches — the half of #522 that survived fixing slugify().
|
||
* - Unicode form. Slugs are stored NFC; a non-Latin slug typed or pasted on
|
||
* macOS can arrive NFD, and the two are different strings that render the
|
||
* same.
|
||
*
|
||
* ASCII slugs are unaffected by either step. No stored slug can contain a '%',
|
||
* since slugify() drops it, so decoding cannot collide with a real slug.
|
||
*/
|
||
export function normalizeSlug(slug: string): string {
|
||
let decoded = slug;
|
||
try {
|
||
decoded = decodeURIComponent(slug);
|
||
} catch {
|
||
// Malformed percent sequence — compare what we were actually given rather
|
||
// than throwing a 500 on a hand-mangled URL.
|
||
}
|
||
return decoded.normalize('NFC');
|
||
}
|