release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -71,6 +71,23 @@ describe('admin session tokens', () => {
expect(verifyAdminToken(createAdminToken())).toBe(true);
});
/**
* A cookie far longer than any token we issue is rejected before it is
* parsed. The decode below the signature check was never reachable without
* the key, so this is boundary hygiene rather than a fix for an exploitable
* exhaustion — but a bound on untrusted input costs nothing (#505).
*/
it('rejects an implausibly long token without parsing it', async () => {
const { createAdminToken, verifyAdminToken } = await loadAuth();
const valid = createAdminToken();
expect(valid.length).toBeLessThan(512);
expect(verifyAdminToken(valid)).toBe(true);
expect(verifyAdminToken('a'.repeat(513))).toBe(false);
// Padding a genuine token past the bound must not sneak through either.
expect(verifyAdminToken(valid + 'a'.repeat(512))).toBe(false);
});
it('rejects a token forged with the old hardcoded dev fallback secret', async () => {
const { verifyAdminToken } = await loadAuth();
// The exact key an attacker could derive from the public source before the fix.

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { albumMetaDetail } from '../albumMeta';
import type { ImmichAlbum, ImmichAsset, ImmichExifInfo } from '../immich';
function asset(fileCreatedAt: string, exif?: Partial<ImmichExifInfo>): ImmichAsset {
return {
id: fileCreatedAt,
type: 'IMAGE',
originalFileName: 'a.jpg',
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt,
isTrashed: false,
...(exif ? { exifInfo: exif as ImmichExifInfo } : {}),
};
}
function album(assets: ImmichAsset[]): ImmichAlbum {
return {
id: 'album',
slug: 'album',
albumName: 'Album',
description: '',
albumThumbnailAssetId: null,
assetCount: assets.length,
assets,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
order: 'asc',
};
}
/**
* Both values used to come from the first asset that happened to carry EXIF, so
* a mixed album advertised whichever camera sorted first — and changing the
* album's sort mode changed the claim (#509).
*/
describe('albumMetaDetail', () => {
it('names the camera when the whole album agrees', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5', lensModel: '35mm F1.4' }),
asset('2026-05-02T10:00:00.000Z', { model: 'X-T5', lensModel: '35mm F1.4' }),
]);
expect(albumMetaDetail(a, true).gear).toBe('X-T5 · 35mm F1.4');
});
it('says nothing about gear when the album mixes bodies', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z', { model: 'M11' }),
]);
expect(albumMetaDetail(a, true).gear).toBeUndefined();
});
it('does not change its claim when the album order changes', () => {
const shot = [
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z', { model: 'M11' }),
];
expect(albumMetaDetail(album(shot), true)).toEqual(
albumMetaDetail(album([...shot].reverse()), true),
);
});
it('keeps a shared camera even when only some photos carry EXIF', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z'),
]);
expect(albumMetaDetail(a, true).gear).toBe('X-T5');
});
it('omits the gear entirely when the camera group is off', () => {
const a = album([asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' })]);
expect(albumMetaDetail(a, false).gear).toBeUndefined();
expect(albumMetaDetail(a, false).date).toBe('05/2026');
});
it('shows one month when the album was shot in one month', () => {
const a = album([asset('2026-05-01T10:00:00.000Z'), asset('2026-05-28T10:00:00.000Z')]);
expect(albumMetaDetail(a, true).date).toBe('05/2026');
});
it('spans a range when the album does', () => {
const a = album([asset('2026-05-01T10:00:00.000Z'), asset('2025-11-02T10:00:00.000Z')]);
expect(albumMetaDetail(a, true).date).toBe('11/2025 – 05/2026');
});
it('prefers the capture time over the upload time', () => {
const a = album([
asset('2026-08-01T10:00:00.000Z', { dateTimeOriginal: '2019-03-04T10:00:00.000Z' }),
]);
expect(albumMetaDetail(a, true).date).toBe('03/2019');
});
it('returns nothing for an empty album', () => {
expect(albumMetaDetail(album([]), true)).toEqual({});
});
});

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { resolveColorMode, COLOR_MODES } from '../config/schema';
/**
* `mode:` decides what a first-time visitor lands on. The toggle used to
* hardcode dark, so an operator who preferred light had no way to give their
* visitors anything else (#512).
*/
describe('resolveColorMode', () => {
it('defaults to dark, as the site always has', () => {
expect(resolveColorMode(undefined)).toBe('dark');
});
it('accepts each supported mode', () => {
for (const mode of COLOR_MODES) {
expect(resolveColorMode(mode)).toBe(mode);
}
});
/** A typo must not silently produce an invalid data-theme on <html>. */
it('falls back to dark on anything else', () => {
expect(resolveColorMode('Light')).toBe('dark');
expect(resolveColorMode('system')).toBe('dark');
expect(resolveColorMode('')).toBe('dark');
});
});

View File

@@ -97,16 +97,20 @@ describe('gallery.yaml per-album heroImage', () => {
expect(call?.[1]).toContain(ALBUM_ID);
});
// The substitution the wiring above depends on: validateUuid never throws, so
// a bad ID degrades to a 404 image rather than taking the whole page down.
it('real validateUuid warns and substitutes rather than throwing', async () => {
/**
* validateUuid never throws — a bad ID must not take the page down. It used to
* substitute the all-zeros UUID, which then travelled on as a real asset ID and
* came back 400 from Immich, while the log claimed it had been ignored (#517).
* Null lets the caller drop the entry, which is what "ignored" should mean.
*/
it('real validateUuid returns null and names the context', async () => {
const actual =
await vi.importActual<typeof import('@/lib/config/parser')>('@/lib/config/parser');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = actual.validateUuid('not-a-uuid', 'gallery.yaml heroImage');
expect(result).toBe('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull();
expect(warn).toHaveBeenCalled();
expect(String(warn.mock.calls[0][0])).toContain('gallery.yaml heroImage');
warn.mockRestore();

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
// Imported from the schema module directly: it is client-safe (no `fs`), and
// going through '@/lib/config' would pull the filesystem loader into the test.
import {
buildCoverGridVars,
COVER_GRID_COLUMNS_MAX,
COVER_GRID_GAP_MAX,
} from '@/lib/config/schema';
describe('buildCoverGridVars', () => {
it('falls back to the site-wide column count', () => {
expect(buildCoverGridVars(undefined, 3)).toEqual({
'--subpage-columns': 3,
'--subpage-columns-tablet': 2,
});
});
it('lets a per-subpage column count win over the site-wide one', () => {
expect(buildCoverGridVars({ columns: 4 }, 3)['--subpage-columns']).toBe(4);
});
it('clamps a hand-edited column count into range', () => {
// `repeat(0, 1fr)` would collapse the grid, `repeat(99, 1fr)` shred it.
expect(buildCoverGridVars({ columns: 0 }, 3)['--subpage-columns']).toBe(1);
expect(buildCoverGridVars({ columns: 99 }, 3)['--subpage-columns']).toBe(
COVER_GRID_COLUMNS_MAX,
);
});
it('falls back to the minimum for a non-numeric column count', () => {
// YAML happily yields NaN for `columns: none`, and repeat(NaN, 1fr) drops
// the whole declaration.
expect(buildCoverGridVars({ columns: Number.NaN }, 3)['--subpage-columns']).toBe(1);
});
it('never puts more than two covers per row on tablet widths', () => {
expect(buildCoverGridVars({ columns: 1 }, 3)['--subpage-columns-tablet']).toBe(1);
expect(buildCoverGridVars({ columns: 2 }, 3)['--subpage-columns-tablet']).toBe(2);
expect(buildCoverGridVars({ columns: 6 }, 3)['--subpage-columns-tablet']).toBe(2);
});
it('omits the gap unless the subpage sets one', () => {
// Without the variable the theme preset's own --subpage-gap stands, which
// is the whole point of not letting the global grid.gap through.
expect(buildCoverGridVars({ columns: 4 }, 3)).not.toHaveProperty('--subpage-gap');
expect(buildCoverGridVars(undefined, 3)).not.toHaveProperty('--subpage-gap');
});
it('emits an explicit gap, including zero', () => {
expect(buildCoverGridVars({ gap: 24 }, 3)['--subpage-gap']).toBe('24px');
// 0 is a deliberate choice (flush covers), not an absent value.
expect(buildCoverGridVars({ gap: 0 }, 3)['--subpage-gap']).toBe('0px');
});
it('clamps the gap on both ends', () => {
expect(buildCoverGridVars({ gap: -10 }, 3)['--subpage-gap']).toBe('0px');
expect(buildCoverGridVars({ gap: 5000 }, 3)['--subpage-gap']).toBe(`${COVER_GRID_GAP_MAX}px`);
});
});

View File

@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import {
checkAuthSecret,
checkProxyHops,
countForwardedHops,
checkAlbumIds,
checkAlbumsShared,
checkPasswords,
checkWritable,
checkImmichCalls,
worstLevel,
} from '../admin/doctor';
describe('checkAuthSecret', () => {
it('accepts a long secret', () => {
expect(checkAuthSecret('a'.repeat(64)).level).toBe('ok');
});
it('warns about a short one', () => {
const f = checkAuthSecret('short');
expect(f.level).toBe('warn');
expect(f.title).toContain('5 characters');
});
it('errors when absent', () => {
expect(checkAuthSecret(undefined).level).toBe('error');
});
/** The report is shown in a browser and pasted into issues. */
it('never repeats the secret itself', () => {
const secret = 'super-secret-value-nobody-should-see';
const f = checkAuthSecret(secret);
expect(JSON.stringify(f)).not.toContain(secret);
});
});
describe('countForwardedHops', () => {
it('counts the entries of a chain', () => {
expect(countForwardedHops('203.0.113.1, 10.0.0.1')).toBe(2);
expect(countForwardedHops('203.0.113.1')).toBe(1);
});
it('treats an absent or empty header as no proxy', () => {
expect(countForwardedHops(null)).toBe(0);
expect(countForwardedHops('')).toBe(0);
expect(countForwardedHops(' , ')).toBe(0);
});
});
/**
* Both directions fail silently today: too high and the lookup runs off the end
* of the chain, too low and the IP comes from a header the client writes.
*/
describe('checkProxyHops', () => {
it('is happy when the value matches what arrived', () => {
expect(checkProxyHops(1, 1, true).level).toBe('ok');
expect(checkProxyHops(0, 0, false).level).toBe('ok');
});
it('warns when a chain arrived but no hops are configured', () => {
const f = checkProxyHops(0, 2, true);
expect(f.level).toBe('warn');
expect(f.detail).toContain('spoofing');
});
it('warns when more hops are configured than arrived', () => {
expect(checkProxyHops(3, 2, true).level).toBe('warn');
});
/** An admin on the LAN legitimately sees no chain — say so, do not accuse. */
it('explains the direct-admin case instead of calling it broken', () => {
const f = checkProxyHops(1, 0, false);
expect(f.level).toBe('warn');
expect(f.detail).toContain('directly');
});
/**
* Next fills X-Forwarded-For with the socket address when a request arrives
* without one, so a lone entry is what a *direct* request looks like. Warning
* on that would fire on every deployment that has no proxy at all.
*/
it('does not mistake the header Next writes itself for a proxy', () => {
const f = checkProxyHops(0, 1, false);
expect(f.level).toBe('ok');
expect(f.title).toContain('No reverse proxy');
});
it('does trust a lone entry when a real proxy header backs it up', () => {
const f = checkProxyHops(0, 1, true);
expect(f.level).toBe('warn');
expect(f.detail).toContain('spoofing');
});
it('accepts one configured hop against one corroborated entry', () => {
expect(checkProxyHops(1, 1, true).level).toBe('ok');
});
});
describe('checkAlbumIds', () => {
const known = [
{ id: 'a', albumName: 'Japan' },
{ id: 'b', albumName: 'Poland' },
];
it('passes when every configured album resolves', () => {
expect(checkAlbumIds(['a', 'b'], known).level).toBe('ok');
});
it('names the ids Immich does not know', () => {
const f = checkAlbumIds(['a', 'ghost'], known);
expect(f.level).toBe('error');
expect(f.detail).toContain('ghost');
});
it('flags an empty gallery', () => {
expect(checkAlbumIds([], known).level).toBe('warn');
});
});
/** Never an error: publishing an unshared album has always worked (#515). */
describe('checkAlbumsShared', () => {
const known = [
{ id: 'a', albumName: 'Japan', shared: true },
{ id: 'b', albumName: 'Private trip', shared: false },
];
it('is quiet when everything published is shared', () => {
expect(checkAlbumsShared(['a'], known).level).toBe('ok');
});
it('warns and names the unshared album', () => {
const f = checkAlbumsShared(['a', 'b'], known);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Private trip');
});
/** An older Immich may not report the flag; silence beats a false alarm. */
it('says nothing when the flag is absent', () => {
expect(checkAlbumsShared(['x'], [{ id: 'x', albumName: 'Unknown' }]).level).toBe('ok');
});
});
describe('checkPasswords', () => {
it('passes when everything is hashed', () => {
expect(checkPasswords([{ label: 'Site', value: 'scrypt:aa:bb' }]).level).toBe('ok');
});
it('warns about plaintext and names where it lives', () => {
const f = checkPasswords([
{ label: 'Site', value: 'scrypt:aa:bb' },
{ label: 'Album japan-2024', value: 'hunter2' },
]);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Album japan-2024');
});
/** Bcrypt cannot be verified at all any more — that is broken, not sloppy. */
it('errors on a leftover bcrypt hash', () => {
expect(checkPasswords([{ label: 'Site', value: '$2b$10$abcdefghijklmno' }]).level).toBe(
'error',
);
});
it('never repeats a password', () => {
const f = checkPasswords([{ label: 'Album x', value: 'hunter2' }]);
expect(JSON.stringify(f)).not.toContain('hunter2');
});
});
describe('checkWritable', () => {
it('passes when nothing is blocked', () => {
expect(checkWritable([]).level).toBe('ok');
});
it('errors and names the paths', () => {
const f = checkWritable(['content/', 'content/.backups']);
expect(f.level).toBe('error');
expect(f.detail).toContain('content/.backups');
});
});
describe('checkImmichCalls', () => {
it('passes when every call came back', () => {
expect(
checkImmichCalls([
{ endpoint: '/server/ping', ok: true },
{ endpoint: '/albums', ok: true },
]).level,
).toBe('ok');
});
it('names the failing endpoint', () => {
const f = checkImmichCalls([
{ endpoint: '/server/ping', ok: true },
{ endpoint: '/albums', ok: false },
]);
expect(f.level).toBe('error');
expect(f.detail).toContain('/albums');
});
});
describe('worstLevel', () => {
it('reports the most severe finding', () => {
expect(worstLevel([{ id: 'a', level: 'ok', title: '', detail: '' }])).toBe('ok');
expect(
worstLevel([
{ id: 'a', level: 'ok', title: '', detail: '' },
{ id: 'b', level: 'warn', title: '', detail: '' },
]),
).toBe('warn');
expect(
worstLevel([
{ id: 'a', level: 'warn', title: '', detail: '' },
{ id: 'b', level: 'error', title: '', detail: '' },
]),
).toBe('error');
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { deriveGallery } from '../config';
import type { GalleryYaml } from '../config';
/**
* The shipped .example files are the ones README, CONTRIBUTING and the setup
* screen all tell a new user to copy. `gallery.yaml.example` carried two
* `subpages:` keys, so js-yaml refused it and a brand-new installation answered
* HTTP 500 on every route — the first thing a user saw was a broken site.
*/
const CONTENT = path.join(process.cwd(), 'content');
function load(file: string): unknown {
return yaml.load(fs.readFileSync(path.join(CONTENT, file), 'utf8'));
}
describe('shipped example files', () => {
it('gallery.yaml.example is valid YAML', () => {
expect(() => load('gallery.yaml.example')).not.toThrow();
});
it('settings.yaml.example is valid YAML', () => {
expect(() => load('settings.yaml.example')).not.toThrow();
});
/** Parsing is not enough — it has to survive the config pipeline too. */
it('gallery.yaml.example produces a usable gallery structure', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const derived = deriveGallery(load('gallery.yaml.example') as GalleryYaml);
expect(derived.subpages.length).toBeGreaterThan(0);
// Every subpage needs a slug to be reachable at all.
for (const sp of derived.subpages) {
expect(sp.slug).toBeTruthy();
}
/*
* No albums, on purpose: the example's IDs are placeholders like
* "album-uuid-3", which are not UUIDs and are therefore dropped with a
* named warning each (#517). A copied example gives a site with page
* structure and no photos, which is honest — before, the placeholders
* became the all-zeros UUID and produced an error page.
*/
expect(derived.albums).toEqual([]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { formatCamera } from '../exif';
/**
* The lightbox printed make and model unconditionally, so a Leica read
* "LEICA CAMERA AG LEICA Q3" — long enough to run into its own label (#514).
*/
describe('formatCamera', () => {
it('drops a maker the model already names', () => {
expect(formatCamera('LEICA CAMERA AG', 'LEICA Q3')).toBe('LEICA Q3');
expect(formatCamera('NIKON CORPORATION', 'NIKON Z 6')).toBe('NIKON Z 6');
expect(formatCamera('Canon', 'Canon EOS R6')).toBe('Canon EOS R6');
});
it('keeps a maker the model does not name', () => {
expect(formatCamera('SONY', 'ILCE-7M4')).toBe('SONY ILCE-7M4');
expect(formatCamera('FUJIFILM', 'X-T5')).toBe('FUJIFILM X-T5');
});
/** The model's own separators count as word breaks. */
it('matches a brand across a hyphen', () => {
expect(formatCamera('OM Digital Solutions', 'OM-1')).toBe('OM-1');
expect(formatCamera('OM Digital Solutions', 'OM1')).toBe('OM1');
});
/** A short brand must not match inside an unrelated word. */
it('does not treat a substring as the brand', () => {
expect(formatCamera('OM Digital Solutions', 'COMPACT 3')).toBe(
'OM Digital Solutions COMPACT 3',
);
});
it('copes with either side missing', () => {
expect(formatCamera('SONY', undefined)).toBe('SONY');
expect(formatCamera(null, 'X-T5')).toBe('X-T5');
expect(formatCamera(' ', ' ')).toBe('');
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { resolveExifDisplay, hasExifPanelContent } from '../config/schema';
describe('resolveExifDisplay', () => {
it('shows everything when nothing is configured', () => {
expect(resolveExifDisplay()).toEqual({
camera: true,
settings: true,
location: true,
caption: true,
onHover: true,
});
});
/**
* `exifOnHover: false` meant "no technical EXIF anywhere" long before the
* groups existed. Existing configs must keep that meaning.
*/
it('keeps the old exifOnHover switch meaning what it did', () => {
expect(resolveExifDisplay(undefined, false)).toMatchObject({
camera: false,
settings: false,
location: false,
onHover: false,
});
});
/**
* The description was served regardless of that switch, so a site that only
* ever set `exifOnHover: false` must not lose its captions on upgrade — it
* never had them switched off (#506).
*/
it('does not let the old switch turn captions off', () => {
expect(resolveExifDisplay(undefined, false).caption).toBe(true);
});
it('lets an explicit group win over the old switch', () => {
const exif = resolveExifDisplay({ camera: true, caption: false }, false);
expect(exif.camera).toBe(true);
expect(exif.caption).toBe(false);
// Unmentioned groups still follow the switch.
expect(exif.location).toBe(false);
// …and the switch still decides the hover overlay.
expect(exif.onHover).toBe(false);
});
it('can hide only the location and the caption', () => {
expect(resolveExifDisplay({ location: false, caption: false })).toEqual({
camera: true,
settings: true,
location: false,
caption: false,
onHover: true,
});
});
});
describe('hasExifPanelContent', () => {
it('is false only when every group is off', () => {
const off = resolveExifDisplay({
camera: false,
settings: false,
location: false,
caption: false,
});
expect(hasExifPanelContent(off)).toBe(false);
});
it('stays true while one group survives', () => {
const captionOnly = resolveExifDisplay({
camera: false,
settings: false,
location: false,
caption: true,
});
expect(hasExifPanelContent(captionOnly)).toBe(true);
});
/** The hover overlay is a separate switch and must not keep the panel alive. */
it('ignores the hover overlay switch', () => {
const hoverOnly = resolveExifDisplay(
{ camera: false, settings: false, location: false, caption: false },
true,
);
expect(hoverOnly.onHover).toBe(true);
expect(hasExifPanelContent(hoverOnly)).toBe(false);
});
});

110
lib/__tests__/i18n.test.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* i18n — locale resolution and dictionary parity.
*
* The parity walk is the point: `de.ts` is typed as `Dictionary`, so a *missing*
* key is already a compile error. What the type cannot catch is a key that was
* copied over but never translated, or a plural function that ignores its
* argument — both of which ship an English string on a German site.
*/
import { describe, it, expect } from 'vitest';
import { resolveLocale, getDictionary, dictionaryFor, SUPPORTED_LOCALES } from '../i18n';
import { en } from '../i18n/locales/en';
import { de } from '../i18n/locales/de';
describe('resolveLocale', () => {
it('accepts the supported locales', () => {
expect(resolveLocale('en')).toBe('en');
expect(resolveLocale('de')).toBe('de');
});
it('drops the region subtag', () => {
expect(resolveLocale('de-DE')).toBe('de');
expect(resolveLocale('de-AT')).toBe('de');
expect(resolveLocale('en_GB')).toBe('en');
});
it('is case- and whitespace-insensitive', () => {
expect(resolveLocale(' DE ')).toBe('de');
expect(resolveLocale('De-Ch')).toBe('de');
});
it('falls back to English for languages without a dictionary', () => {
expect(resolveLocale('fr')).toBe('en');
expect(resolveLocale('ja-JP')).toBe('en');
expect(resolveLocale('')).toBe('en');
expect(resolveLocale(undefined)).toBe('en');
expect(resolveLocale(null)).toBe('en');
});
});
describe('getDictionary', () => {
it('returns a dictionary for every supported locale', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(getDictionary(locale)).toBeDefined();
}
});
it('dictionaryFor resolves and looks up in one step', () => {
expect(dictionaryFor('de-DE')).toBe(de);
expect(dictionaryFor('fr')).toBe(en);
});
});
/** Every leaf of a dictionary, as `['nav.home', 'Home']` pairs. */
function leaves(obj: object, prefix = ''): [string, unknown][] {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return value !== null && typeof value === 'object'
? leaves(value, path)
: ([[path, value]] as [string, unknown][]);
});
}
/** Call a dictionary function with plausible arguments for its arity. */
function invoke(fn: (...args: never[]) => string): string {
const args = Array.from({ length: fn.length }, (_, i) => (i === 0 ? 2 : `arg${i}`));
return (fn as (...a: unknown[]) => string)(...args);
}
describe('dictionary parity', () => {
const enLeaves = leaves(en);
const deLeaves = new Map(leaves(de));
it('German covers every English key', () => {
expect([...deLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
});
it('matches value kinds — a string never stands in for an interpolator', () => {
for (const [path, value] of enLeaves) {
expect(typeof deLeaves.get(path), path).toBe(typeof value);
}
});
it('leaves no English string untranslated', () => {
// Proper nouns and units read the same in both languages; `coverAria` is
// pure interpolation with no words of its own.
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']);
const identical = enLeaves
.filter(([path]) => !shared.has(path))
.filter(([path, value]) => {
const other = deLeaves.get(path);
if (typeof value === 'function') {
return invoke(other as (...a: never[]) => string) === invoke(value as never);
}
return other === value;
})
.map(([path]) => path);
expect(identical).toEqual([]);
});
it('plural helpers actually vary on their count', () => {
for (const dict of [en, de]) {
expect(dict.common.photos(1)).not.toBe(dict.common.photos(2));
expect(dict.common.albums(1)).not.toBe(dict.common.albums(2));
expect(dict.common.collections(1)).not.toBe(dict.common.collections(2));
}
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as config from '../config';
import { cache } from '../cache';
/**
* A deployment with credentials in the environment but no content/gallery.yaml
* is `needsSetup`, yet Immich is perfectly reachable. The client used to gate
* every request on that flag, so it returned null before the fetch — the admin
* panel reported "Immich disconnected" and nothing at all was logged (#507).
*/
const state = {
apiUrl: 'http://immich.test/api',
apiKey: 'test-key',
needsSetup: true,
needsCredentials: false,
needsGallery: true,
albums: [] as string[],
};
vi.mock('../config', async () => {
const actual = await vi.importActual<typeof config>('../config');
return {
...actual,
getConfig: () => ({
immich: { apiUrl: state.apiUrl, apiKey: state.apiKey },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: state.albums,
standaloneAlbums: [],
subpages: [],
albumOverrides: {},
albumDescriptions: {},
albumSortModes: {},
albumManualOrders: {},
cacheTtl: 60_000,
staleMaxAge: 86_400_000,
immichTimeoutMs: 15000,
needsSetup: state.needsSetup,
needsCredentials: state.needsCredentials,
needsGallery: state.needsGallery,
}),
};
});
global.fetch = vi.fn();
const { immich } = await import('../immich');
describe('ImmichClient — install state', () => {
const mockFetch = global.fetch as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
state.apiUrl = 'http://immich.test/api';
state.apiKey = 'test-key';
state.needsCredentials = false;
state.needsSetup = true;
state.needsGallery = true;
state.albums = [];
cache.clear();
});
it('pings Immich even without a gallery.yaml', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ res: 'pong' }),
});
await expect(immich.ping()).resolves.toBe(true);
expect(mockFetch).toHaveBeenCalledWith('http://immich.test/api/server/ping', expect.anything());
});
it('reports an unreachable Immich as disconnected rather than throwing', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(immich.ping()).resolves.toBe(false);
});
/**
* The setup config carries an empty allowlist, so the filtered list is [] no
* matter what Immich returned. Caching that made the gallery look empty until
* the server restarted, because the wizard's invalidateAll() runs in another
* bundle's module instance and never reached this cache.
*/
it('does not cache the empty album list while setup is unfinished', async () => {
const album = {
id: 'album-1',
albumName: 'Japan',
description: '',
albumThumbnailAssetId: null,
assetCount: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
const respond = () => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [album],
});
mockFetch.mockResolvedValueOnce(respond());
await expect(immich.getAlbums()).resolves.toEqual([]);
// The wizard finishes: credentials unchanged, but now there is a gallery.
state.needsSetup = false;
state.needsGallery = false;
state.albums = ['album-1'];
mockFetch.mockResolvedValueOnce(respond());
const after = await immich.getAlbums();
expect(after.map((a) => a.id)).toEqual(['album-1']);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('makes no request at all when credentials are absent', async () => {
state.apiUrl = '';
state.apiKey = '';
state.needsCredentials = true;
await expect(immich.ping()).resolves.toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
// A getter, not a value: the mock factory runs once, but each test installs
// into a fresh temp directory.
vi.mock('@/lib/env', () => ({
env: {
get INSTALL_CONTENT_DIR() {
return process.env.INSTALL_CONTENT_DIR;
},
},
}));
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-install-'));
process.env.INSTALL_CONTENT_DIR = dir;
vi.resetModules();
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
delete process.env.INSTALL_CONTENT_DIR;
});
async function install(input: Record<string, unknown>) {
const { completeInstall } = await import('@/lib/install');
await completeInstall({
apiUrl: 'http://immich.test',
apiKey: 'key',
theme: 'studio-modern',
...input,
} as Parameters<typeof completeInstall>[0]);
return yaml.load(fs.readFileSync(path.join(dir, 'gallery.yaml'), 'utf8')) as {
hero: string[];
albums: string[];
};
}
/**
* The wizard always wrote `hero: []`, so the first thing a finished portfolio
* showed was a list of album names and no photograph (#518).
*/
describe('completeInstall', () => {
it('writes the seeded hero assets', async () => {
const gallery = await install({
albums: ['11111111-1111-1111-1111-111111111111'],
hero: ['22222222-2222-2222-2222-222222222222'],
});
expect(gallery.hero).toEqual(['22222222-2222-2222-2222-222222222222']);
expect(gallery.albums).toEqual(['11111111-1111-1111-1111-111111111111']);
});
/** Seeding is a nicety; an install without it is still a valid install. */
it('writes an empty hero when none could be found', async () => {
const gallery = await install({ albums: [], hero: [] });
expect(gallery.hero).toEqual([]);
});
it('tolerates the field being omitted entirely', async () => {
const gallery = await install({ albums: [] });
expect(gallery.hero).toEqual([]);
});
});

View File

@@ -1,7 +1,22 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
/*
* The gate is mocked rather than driven through a real config: proxy() now
* consults it on every request, and reading the repository's own
* content/settings.yaml would make these tests depend on how the working copy
* happens to be configured. lib/__tests__/site-password.test.ts covers the
* verification itself.
*/
vi.mock('@/lib/auth', () => ({
isSiteUnlocked: vi.fn(() => true),
}));
import { isSiteUnlocked } from '@/lib/auth';
import { proxy, config } from '@/proxy';
const mockUnlocked = isSiteUnlocked as unknown as ReturnType<typeof vi.fn>;
// Next.js 16 renamed the "middleware" file convention to "proxy": the file must
// be proxy.ts and must export proxy(), not middleware(). A silent regression here
// (wrong filename, wrong export name) means Next.js never invokes this code and
@@ -9,6 +24,10 @@ import { proxy, config } from '@/proxy';
describe('proxy', () => {
const run = (pathname = '/') => proxy(new NextRequest(`https://example.com${pathname}`));
beforeEach(() => {
mockUnlocked.mockReturnValue(true);
});
it('is exported under the name Next.js 16 expects', () => {
expect(typeof proxy).toBe('function');
});
@@ -63,9 +82,102 @@ describe('proxy', () => {
});
it('keeps a matcher that excludes api and static assets but covers /admin', () => {
const source = config.matcher[0].source;
const source = config.matcher[0];
expect(source).toContain('api');
expect(source).toContain('_next/static');
expect(source).not.toContain('admin');
});
/**
* The gate resolves the site password through getConfig(), which throws on a
* config it cannot parse — inside the proxy, before app/layout.tsx can fall
* back to the setup screen. Unhandled, one typo in gallery.yaml became a bare
* 500 on every route (#516).
*/
it('serves the page instead of a 500 when the config cannot be read', () => {
mockUnlocked.mockImplementation(() => {
throw new Error('duplicated mapping key');
});
const res = proxy(new NextRequest('https://example.com/japan'));
// Not a rewrite to the gate, and not a throw: the page renders and shows
// the setup screen, which is the thing that can explain the fault.
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
expect(res.headers.get('Content-Security-Policy')).toBeTruthy();
});
it('still gates a locked site when the config is fine', () => {
mockUnlocked.mockReturnValue(false);
const res = proxy(new NextRequest('https://example.com/japan'));
expect(res.headers.get('x-middleware-rewrite')).toContain('/gate');
});
it('no longer lets the matcher skip prefetches', () => {
/*
* The exclusion used to live in the matcher. It moved into proxy() when the
* site gate was added: a matcher that skips prefetches skips the gate with
* them, and a prefetch asks for the same RSC payload as a navigation — so
* one request header would have walked straight past a locked site.
*/
expect(JSON.stringify(config.matcher)).not.toContain('next-router-prefetch');
});
it('still leaves prefetches without a nonce or a policy', () => {
const prefetch = proxy(
new NextRequest('https://example.com/', { headers: { 'next-router-prefetch': '1' } }),
);
expect(prefetch.headers.get('Content-Security-Policy')).toBeNull();
expect(prefetch.headers.get('x-middleware-request-x-nonce')).toBeNull();
const purpose = proxy(
new NextRequest('https://example.com/', { headers: { purpose: 'prefetch' } }),
);
expect(purpose.headers.get('Content-Security-Policy')).toBeNull();
});
describe('site-wide gate', () => {
/** The header a rewrite response carries, holding the rewritten URL. */
const rewrittenTo = (res: Response) => res.headers.get('x-middleware-rewrite');
it('rewrites a locked-out visitor to the gate instead of rendering the page', () => {
mockUnlocked.mockReturnValue(false);
// A redirect would lose the requested URL; a rewrite keeps it, so
// unlocking lands the visitor where they were going.
expect(rewrittenTo(run('/japan/osaka-2023'))).toContain('/gate');
});
it('gates a prefetch too', () => {
mockUnlocked.mockReturnValue(false);
const res = proxy(
new NextRequest('https://example.com/japan', {
headers: { 'next-router-prefetch': '1' },
}),
);
// A prefetch asks for the same payload as a navigation. If the gate ran
// after the prefetch shortcut, one header would walk straight past it.
expect(rewrittenTo(res)).toContain('/gate');
});
it('leaves /admin and /install reachable', () => {
mockUnlocked.mockReturnValue(false);
// /admin owns its own password and is where the site password is set;
// /install is what a fresh deployment needs before it can have one.
expect(rewrittenTo(run('/admin'))).toBeNull();
expect(rewrittenTo(run('/admin/settings/general'))).toBeNull();
expect(rewrittenTo(run('/install'))).toBeNull();
});
it('does not rewrite the gate to itself', () => {
mockUnlocked.mockReturnValue(false);
expect(rewrittenTo(run('/gate'))).toBeNull();
});
it('stays out of the way when the site is unlocked', () => {
expect(rewrittenTo(run('/japan/osaka-2023'))).toBeNull();
expect(run().headers.get('Content-Security-Policy')).toBeTruthy();
});
});
});

View File

@@ -1,9 +1,19 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
/*
* getClientIp() reads the hop count straight from the environment, not through
* getConfig(): the config resolves AUTH_SECRET, which throws in production when
* none is set — and that took down the install wizard's own API on a container
* started with no environment at all (#519).
*/
let mockHops = 0;
vi.mock('@/lib/config', () => ({
getConfig: () => ({ trustedProxyHops: mockHops }),
vi.mock('@/lib/env', () => ({
env: {
get TRUSTED_PROXY_HOPS() {
return mockHops;
},
},
}));
import {

View File

@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The site-wide gate rides on the same HMAC machinery as subpage, album and
* journal passwords — the point of these tests is that it really is the same
* path (signed expiry, constant-time compare, opt-in when unset) and not a
* second, weaker scheme bolted on beside it.
*/
const config = {
authSecret: 'test-secret-that-is-at-least-32-chars-long',
sitePassword: '',
subpages: [] as unknown[],
albumPasswords: {} as Record<string, string>,
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
}));
import {
authenticate,
isAuthenticated,
isSiteLocked,
isSiteUnlocked,
siteLockResponse,
SITE_AUTH_COOKIE,
SITE_AUTH_KEY,
} from '@/lib/auth';
/** Pull the cookie value back out of a Set-Cookie header. */
function cookieValue(setCookie: string): string {
return setCookie.slice(setCookie.indexOf('=') + 1, setCookie.indexOf(';'));
}
const jar = (value?: string) => (name: string) => (name === SITE_AUTH_COOKIE ? value : undefined);
beforeEach(() => {
config.sitePassword = '';
});
describe('an open site', () => {
it('is not locked when no password is configured', () => {
expect(isSiteLocked()).toBe(false);
});
it('serves everyone, cookie or not', () => {
expect(isSiteUnlocked(jar())).toBe(true);
expect(siteLockResponse(new Request('http://localhost/api/image/x'))).toBeNull();
});
it('refuses to issue a token — there is nothing to unlock', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'anything', 'site')).toBeNull();
});
});
describe('a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('reports itself locked', () => {
expect(isSiteLocked()).toBe(true);
});
it('turns away a visitor with no cookie', () => {
expect(isSiteUnlocked(jar())).toBe(false);
});
it('rejects the wrong password', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'wrong', 'site')).toBeNull();
});
it('issues an HttpOnly cookie for the right password', async () => {
const setCookie = await authenticate(SITE_AUTH_KEY, 'letmein', 'site');
expect(setCookie).toContain(`${SITE_AUTH_COOKIE}=`);
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('SameSite=Strict');
expect(setCookie).toContain('Path=/');
});
it('accepts the cookie it just issued', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(true);
});
it('rejects a token whose expiry has been pushed out by hand', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const [, mac] = cookieValue(setCookie).split('.');
const forged = `${Date.now() + 10 * 365 * 24 * 3600_000}.${mac}`;
expect(isSiteUnlocked(jar(forged))).toBe(false);
});
it('rejects an expired token', () => {
// Signed with a past expiry: the signature is valid, the deadline is not.
expect(isSiteUnlocked(jar(`${Date.now() - 1000}.deadbeef`))).toBe(false);
});
it('rejects garbage', () => {
expect(isSiteUnlocked(jar(''))).toBe(false);
expect(isSiteUnlocked(jar('not-a-token'))).toBe(false);
expect(isSiteUnlocked(jar('...'))).toBe(false);
});
it('does not unlock the site with another gate’s cookie', async () => {
config.subpages = [{ slug: 'private', password: 'letmein' }];
const subpageCookie = (await authenticate('private', 'letmein', 'subpage'))!;
// Same password, different key — the key is part of the signed payload.
expect(isSiteUnlocked(jar(cookieValue(subpageCookie)))).toBe(false);
config.subpages = [];
});
it('stops changing its mind once the password is rotated', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
config.sitePassword = 'something-else';
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(false);
});
});
describe('siteLockResponse', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('answers 401 without a cookie, and does not let it be cached', async () => {
const res = siteLockResponse(new Request('http://localhost/api/image/x'))!;
expect(res.status).toBe(401);
expect(res.headers.get('Cache-Control')).toBe('no-store');
// A WWW-Authenticate header would make the browser pop its own dialog.
expect(res.headers.get('WWW-Authenticate')).toBeNull();
});
it('reads the cookie out of the request header', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `theme=dark; ${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}; other=1` },
});
expect(siteLockResponse(req)).toBeNull();
});
it('is not fooled by a cookie whose name merely ends the same way', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `not_${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}` },
});
expect(siteLockResponse(req)).not.toBeNull();
});
});
describe('a subpage slugged "site"', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('does not write to the site gate’s cookie', async () => {
config.subpages = [{ slug: 'site', password: 'subpage-pw' }];
const setCookie = (await authenticate('site', 'subpage-pw', 'subpage'))!;
expect(setCookie.startsWith(`${SITE_AUTH_COOKIE}=`)).toBe(false);
expect(setCookie).toContain('lb_auth_site=');
config.subpages = [];
});
it('does not unlock the site even when both passwords match', async () => {
// Same key ("site"), same password — only the domain separator in the
// signed payload keeps the two tokens apart.
config.subpages = [{ slug: 'site', password: 'letmein' }];
const subpageCookie = cookieValue((await authenticate('site', 'letmein', 'subpage'))!);
expect(isSiteUnlocked(jar(subpageCookie))).toBe(false);
config.subpages = [];
});
it('is not unlocked by the site cookie either', async () => {
config.subpages = [{ slug: 'site', password: 'letmein' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
expect(isAuthenticated('site', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});
describe('the other gates are untouched', () => {
it('still protects a subpage independently of the site lock', async () => {
config.sitePassword = 'letmein';
config.subpages = [{ slug: 'private', password: 'other' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
// Unlocking the site does not unlock a subpage inside it.
expect(isAuthenticated('private', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { resolveWatermarkOpacity, WATERMARK_DEFAULT_OPACITY } from '../config/schema';
/**
* The lightbox used to divide `watermark.opacity` by 100 while both the docs
* and the admin slider expressed it as a fraction, so a configured 0.9 rendered
* as 0.009 — invisible (#508).
*/
describe('resolveWatermarkOpacity', () => {
it('passes a documented fraction through unchanged', () => {
expect(resolveWatermarkOpacity(0.5)).toBe(0.5);
expect(resolveWatermarkOpacity(0.9)).toBe(0.9);
});
it('reads a value above 1 as a percentage', () => {
expect(resolveWatermarkOpacity(90)).toBe(0.9);
expect(resolveWatermarkOpacity(50)).toBe(0.5);
});
it('treats 1 as fully opaque, not as one percent', () => {
expect(resolveWatermarkOpacity(1)).toBe(1);
});
it('clamps out-of-range values into 0–1', () => {
expect(resolveWatermarkOpacity(400)).toBe(1);
expect(resolveWatermarkOpacity(-2)).toBe(0);
});
it('falls back to the default when unset or not a number', () => {
expect(resolveWatermarkOpacity(undefined)).toBe(WATERMARK_DEFAULT_OPACITY);
expect(resolveWatermarkOpacity(NaN)).toBe(WATERMARK_DEFAULT_OPACITY);
});
it('keeps 0 as fully transparent rather than falling back', () => {
expect(resolveWatermarkOpacity(0)).toBe(0);
});
});

View File

@@ -78,8 +78,24 @@ export function createAdminToken(): string {
return `${data}.${sig}`;
}
/**
* Upper bound on a session token, well above the ~200 characters a real one
* needs. A token that long is not one we issued, so rejecting it early costs a
* legitimate visitor nothing.
*
* The work being avoided is modest and worth stating honestly: the base64url
* decode below only runs once the HMAC matches, which an attacker cannot
* produce — so the unbounded part is the HMAC over the token itself, and the
* server's own header limit already caps how much of it can arrive. This is
* hygiene at the boundary rather than a fix for a reachable exhaustion.
* Reported as #505.
*/
const MAX_TOKEN_LENGTH = 512;
/** Verify a session token. Returns true if valid. */
export function verifyAdminToken(token: string): boolean {
if (token.length > MAX_TOKEN_LENGTH) return false;
const parts = token.split('.');
if (parts.length !== 2) return false;

347
lib/admin/doctor.ts Normal file
View File

@@ -0,0 +1,347 @@
/**
* Config doctor — the handful of misconfigurations behind almost every
* "it does not work for me" report (#491).
*
* Every check is a pure function over inputs the caller has already gathered,
* so the whole set is unit-testable without mocking fs, Immich or a request.
* The route does the gathering; this module does the judging.
*
* A finding never carries a secret. Not the AUTH_SECRET, not a password, not
* the API key — only whether something is set, and where to look.
*/
export type DoctorLevel = 'ok' | 'warn' | 'error';
export interface DoctorFinding {
/** Stable identifier, so the UI and a future CLI can agree on a check. */
id: string;
level: DoctorLevel;
title: string;
/** One sentence: what was observed, and what to do about it. */
detail: string;
}
/** The worst level present — what the status badge should show. */
export function worstLevel(findings: DoctorFinding[]): DoctorLevel {
if (findings.some((f) => f.level === 'error')) return 'error';
if (findings.some((f) => f.level === 'warn')) return 'warn';
return 'ok';
}
/**
* `AUTH_SECRET` derives the asset-token key and every auth cookie's HMAC.
* Missing in production throws at startup, so the case that actually reaches a
* running site is a secret that is set but too short to be worth much.
*/
export const AUTH_SECRET_MIN_LENGTH = 32;
export function checkAuthSecret(secret: string | undefined): DoctorFinding {
const length = secret?.length ?? 0;
if (!length) {
return {
id: 'auth-secret',
level: 'error',
title: 'AUTH_SECRET is not set',
detail:
'Asset tokens and every auth cookie are signed with a secret regenerated on each ' +
'restart, so links and logins break whenever the server restarts. Set AUTH_SECRET to a ' +
'long random string.',
};
}
if (length < AUTH_SECRET_MIN_LENGTH) {
return {
id: 'auth-secret',
level: 'warn',
title: `AUTH_SECRET is only ${length} characters`,
detail: `Use at least ${AUTH_SECRET_MIN_LENGTH} random characters — it is the key behind every asset token and auth cookie.`,
};
}
return {
id: 'auth-secret',
level: 'ok',
title: 'AUTH_SECRET is set',
detail: `${length} characters.`,
};
}
/**
* `TRUSTED_PROXY_HOPS` says how far from the right of `X-Forwarded-For` the
* real client IP sits. Wrong values fail silently: too high and the lookup
* falls off the end of the chain, too low and the IP comes from a header the
* client can write — either way rate limiting stops telling visitors apart,
* and nothing in the log says so.
*
* The measurement is trickier than it looks. Next fills in every `x-forwarded-*`
* header itself when the request arrives without one
* (`base-server.js`: `req.headers['x-forwarded-for'] ??= socket.remoteAddress`),
* so a chain of exactly one entry is what a *direct* request looks like too —
* warning on it would fire on every deployment that has no proxy at all. A
* single entry therefore only counts as evidence of a proxy when something Next
* does not synthesise is present as well: `x-real-ip`, `forwarded` or `via`.
*
* Even then this is evidence, not proof: an admin reaching the panel directly on
* the LAN sees a different path than public traffic through the reverse proxy.
*/
export function checkProxyHops(
configuredHops: number,
observedHops: number,
/** A header a real proxy sets and Next never invents. */
hasProxyMarker = false,
): DoctorFinding {
// One entry with nothing to corroborate it is what Next writes for a direct
// request. Not evidence of anything.
const inconclusive = observedHops === 1 && !hasProxyMarker;
const provenHops = inconclusive ? 0 : observedHops;
if (provenHops === 0 && configuredHops === 0) {
return {
id: 'proxy-hops',
level: 'ok',
title: 'No reverse proxy detected',
detail: inconclusive
? 'The only X-Forwarded-For entry is the one Next fills in for a direct request, which matches TRUSTED_PROXY_HOPS=0.'
: 'This request arrived without X-Forwarded-For, matching TRUSTED_PROXY_HOPS=0.',
};
}
if (provenHops === 0 && configuredHops > 0) {
return {
id: 'proxy-hops',
level: 'warn',
title: `TRUSTED_PROXY_HOPS is ${configuredHops}, but this request shows no proxy`,
detail:
'Either you reached the admin panel directly while public traffic goes through a proxy — ' +
'in which case this is fine — or the value is too high and every visitor shares one rate-limit bucket.',
};
}
if (configuredHops === 0) {
return {
id: 'proxy-hops',
level: 'warn',
title: `A proxy chain of ${provenHops} was seen, but TRUSTED_PROXY_HOPS is 0`,
detail:
'The client IP is read from a header the client can set, so rate limiting can be bypassed ' +
`by spoofing it. Set TRUSTED_PROXY_HOPS to ${provenHops} (nginx, Traefik or Caddy alone = 1).`,
};
}
if (configuredHops > provenHops) {
return {
id: 'proxy-hops',
level: 'warn',
title: `TRUSTED_PROXY_HOPS is ${configuredHops}, but only ${provenHops} proxy ${
provenHops === 1 ? 'hop was' : 'hops were'
} seen`,
detail:
'The position being read lies before the start of the chain, so the client IP falls back to ' +
`something unreliable. ${provenHops} is what this request suggests.`,
};
}
return {
id: 'proxy-hops',
level: 'ok',
title: `TRUSTED_PROXY_HOPS=${configuredHops} matches the observed chain`,
detail: `X-Forwarded-For carried ${provenHops} ${provenHops === 1 ? 'entry' : 'entries'} on this request.`,
};
}
/** Headers a reverse proxy sets and Next never synthesises. */
export const PROXY_MARKER_HEADERS = ['x-real-ip', 'forwarded', 'via'];
/** Counts the entries of an `X-Forwarded-For` header. Absent or empty is 0. */
export function countForwardedHops(header: string | null | undefined): number {
if (!header) return 0;
return header
.split(',')
.map((part) => part.trim())
.filter(Boolean).length;
}
export interface AlbumRef {
id: string;
albumName: string;
/** Whether Immich reports the album as shared. */
shared?: boolean;
}
/**
* Album IDs in gallery.yaml that Immich no longer knows.
*
* `validateUuid()` deliberately warns rather than throwing, so a typo or a
* deleted album turns into a page that is silently missing an album.
*/
export function checkAlbumIds(configured: string[], known: AlbumRef[]): DoctorFinding {
const ids = new Set(known.map((a) => a.id));
const missing = configured.filter((id) => !ids.has(id));
if (!configured.length) {
return {
id: 'album-ids',
level: 'warn',
title: 'No albums configured',
detail: 'gallery.yaml lists no albums, so the gallery has nothing to show.',
};
}
if (missing.length) {
return {
id: 'album-ids',
level: 'error',
title: `${missing.length} of ${configured.length} album IDs do not exist in Immich`,
detail: `Those pages are silently empty. Check gallery.yaml for: ${missing.join(', ')}`,
};
}
return {
id: 'album-ids',
level: 'ok',
title: `All ${configured.length} album IDs resolve`,
detail: 'Every album in gallery.yaml exists in Immich.',
};
}
/**
* Published albums that are not shared in Immich.
*
* A warning, never an error: the `?shared=true` request filter has no effect on
* current Immich (#515), so publishing an unshared album has always worked.
* Naming them restores the accident-prevention this was meant to give.
*/
export function checkAlbumsShared(configured: string[], known: AlbumRef[]): DoctorFinding {
const byId = new Map(known.map((a) => [a.id, a]));
const unshared = configured
.map((id) => byId.get(id))
.filter((a): a is AlbumRef => !!a && a.shared === false);
if (!unshared.length) {
return {
id: 'albums-shared',
level: 'ok',
title: 'Every published album is shared in Immich',
detail: 'Nothing is published that Immich still considers private.',
};
}
return {
id: 'albums-shared',
level: 'warn',
title: `${unshared.length} published ${unshared.length === 1 ? 'album is' : 'albums are'} not shared in Immich`,
detail:
'They are served to visitors regardless — the allowlist in gallery.yaml is what decides. ' +
`Worth a look if it was unintentional: ${unshared.map((a) => a.albumName).join(', ')}`,
};
}
export interface PasswordRef {
/** Where it lives, for the report: "Album japan-2024", "Site password". */
label: string;
value: string;
}
/**
* Passwords still stored in plaintext, or as a bcrypt hash that can no longer
* be verified at all.
*
* lib/auth.ts warns about both on every login attempt — to the server log,
* where nobody looks.
*/
export function checkPasswords(passwords: PasswordRef[]): DoctorFinding {
const bcrypt = passwords.filter((p) => /^\$2[aby]\$/.test(p.value));
const plaintext = passwords.filter(
(p) => !p.value.startsWith('scrypt:') && !/^\$2[aby]\$/.test(p.value),
);
if (bcrypt.length) {
return {
id: 'passwords',
level: 'error',
title: `${bcrypt.length} password${bcrypt.length === 1 ? '' : 's'} still use bcrypt`,
detail:
'Bcrypt support was removed, so nobody can unlock these at all. Replace with plaintext ' +
`once, log in, and paste the scrypt: hash from the log: ${bcrypt.map((p) => p.label).join(', ')}`,
};
}
if (plaintext.length) {
return {
id: 'passwords',
level: 'warn',
title: `${plaintext.length} password${plaintext.length === 1 ? ' is' : 's are'} stored in plaintext`,
detail:
'Anyone who reads the file reads the password. Log in once and replace it with the ' +
`scrypt: hash printed to the server log: ${plaintext.map((p) => p.label).join(', ')}`,
};
}
if (!passwords.length) {
return {
id: 'passwords',
level: 'ok',
title: 'No passwords configured',
detail: 'Nothing on this site is password-protected.',
};
}
return {
id: 'passwords',
level: 'ok',
title: `All ${passwords.length} passwords are hashed`,
detail: 'Every configured password uses a scrypt: hash.',
};
}
/**
* The whole content directory must be writable: the wizard, the admin panel,
* the journal, favicon upload, analytics and backup rotation all write there.
*/
export function checkWritable(unwritable: string[]): DoctorFinding {
if (unwritable.length) {
return {
id: 'content-writable',
level: 'error',
title: `${unwritable.length} content ${unwritable.length === 1 ? 'path is' : 'paths are'} not writable`,
detail:
'Saving from the admin panel will fail, and so will backups. Check the ownership of the ' +
`mounted volume: ${unwritable.join(', ')}`,
};
}
return {
id: 'content-writable',
level: 'ok',
title: 'content/ is writable',
detail: 'Config, journal and backups can be saved.',
};
}
/**
* Whether the calls Folio actually makes to Immich succeed.
*
* Deliberately not phrased as "are the API key permissions sufficient": Immich
* does not report what a key may do, so the only honest answer is whether the
* three requests this app depends on came back.
*/
export function checkImmichCalls(results: Array<{ endpoint: string; ok: boolean }>): DoctorFinding {
const failed = results.filter((r) => !r.ok);
if (failed.length) {
return {
id: 'immich-api',
level: 'error',
title: `${failed.length} of ${results.length} Immich calls failed`,
detail: `Check IMMICH_API_URL and the API key. Failing: ${failed.map((f) => f.endpoint).join(', ')}`,
};
}
return {
id: 'immich-api',
level: 'ok',
title: 'Immich answers every call Folio makes',
detail: results.map((r) => r.endpoint).join(', '),
};
}

64
lib/albumMeta.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Derivations for the album header — what a whole album can honestly say about
* itself. Server-side only by convention, but there is nothing here that needs
* `fs`, so the unit tests import it directly.
*/
import type { ImmichAlbum } from './immich';
/**
* Extra detail for the album header — shooting date and camera/lens. Both are
* rendered in their own spans and hidden by default; only presets that ask for
* them show them, so the header keeps reading "86 photos" everywhere else.
*
* Both used to be read off the first asset that happened to carry EXIF, so an
* album shot on three bodies advertised whichever one sorted first — and
* changing the album's sort mode changed the camera it claimed (#509). The
* header now only states what the whole album agrees on, and spans a date range
* when the photos do.
*/
export function albumMetaDetail(
album: ImmichAlbum,
showGear: boolean,
): { date?: string; gear?: string } {
const dates = album.assets
.map((a) => a.exifInfo?.dateTimeOriginal || a.fileCreatedAt)
.filter((d): d is string => !!d)
.map((d) => new Date(d).getTime())
.filter((t) => !Number.isNaN(t))
.sort((a, b) => a - b);
const first = dates[0];
const last = dates[dates.length - 1];
const from = first !== undefined ? formatMonthYear(new Date(first).toISOString()) : undefined;
const to = last !== undefined ? formatMonthYear(new Date(last).toISOString()) : undefined;
const date = from && to ? (from === to ? from : `${from} – ${to}`) : from || to;
return {
...(date ? { date } : {}),
...(showGear ? withGear(album) : {}),
};
}
/**
* The camera and lens, but only when every photo that names one names the same
* one. A mixed album says nothing rather than picking a winner.
*/
function withGear(album: ImmichAlbum): { gear?: string } {
const unique = (values: (string | null | undefined)[]): string | undefined => {
const set = new Set(values.filter((v): v is string => !!v));
return set.size === 1 ? [...set][0] : undefined;
};
const model = unique(album.assets.map((a) => a.exifInfo?.model));
const lens = unique(album.assets.map((a) => a.exifInfo?.lensModel));
const gear = [model, lens].filter(Boolean).join(' · ');
return gear ? { gear } : {};
}
function formatMonthYear(iso: string): string | undefined {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}

View File

@@ -1,5 +1,5 @@
/**
* Album authentication helpers.
* Password gating for subpages, albums, journal entries and the site as a whole.
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
*
* Token = HMAC-SHA256(slug + passwordSecret, authSecret)
@@ -12,6 +12,42 @@ import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
const TOKEN_EXPIRY_HOURS = 24;
/** What a password can protect. */
export type ProtectedType = 'subpage' | 'album' | 'journal' | 'site';
/**
* Key used for the site-wide gate. There is only ever one, so it is a constant
* rather than a slug — but it still flows through the same token, cookie and
* verification path as every other protected thing.
*
* This is the *public* identifier, the one the gate form posts to `/api/auth`.
* The value that goes into the HMAC is `SITE_TOKEN_KEY`, see below.
*/
export const SITE_AUTH_KEY = 'site';
/**
* Domain separator for the site token.
*
* A subpage may legitimately be slugged `site`, and a subpage's key is its
* slug — so signing the site token as `site` would sign both gates with the
* same key. With matching passwords the two tokens are then byte-identical and
* either gate accepts the other's cookie. `slugify()` cannot emit an
* underscore, so no slug can collide with this.
*/
const SITE_TOKEN_KEY = '__site__';
/** The key that actually gets signed: only the site gate is remapped. */
function tokenKey(key: string, type: ProtectedType): string {
return type === 'site' ? SITE_TOKEN_KEY : key;
}
const TYPE_LABELS: Record<ProtectedType, string> = {
subpage: 'Subpage',
album: 'Album',
journal: 'Journal entry',
site: 'Site',
};
/**
* Check if a string looks like a legacy bcrypt hash.
*/
@@ -40,12 +76,22 @@ import nodeFs from 'fs';
import { parseFrontmatter } from './journal';
import { resolveJournalFilePath } from './admin/journal-service';
function cookieName(key: string, type: 'subpage' | 'album' | 'journal'): string {
function cookieName(key: string, type: ProtectedType): string {
if (type === 'subpage') return `lb_auth_${key}`;
if (type === 'journal') return `lb_auth_journal_${key}`;
if (type === 'site') return SITE_AUTH_COOKIE;
return `lb_auth_album_${key}`;
}
/**
* Cookie carrying the site-wide session.
*
* Deliberately *not* `lb_auth_site`: subpage cookies are `lb_auth_<slug>`, so a
* subpage slugged `site` would write to the site gate's cookie and log the
* visitor out of the site on every subpage unlock.
*/
export const SITE_AUTH_COOKIE = 'lb_site_auth';
/**
* Find the SubpageConfig for a given slug.
*/
@@ -56,8 +102,11 @@ export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
/**
* Find the password secret for a given subpage slug, album ID, or journal entry.
*/
function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): string | undefined {
function findPassword(key: string, type: ProtectedType): string | undefined {
const config = getConfig();
if (type === 'site') {
return config.sitePassword || undefined;
}
if (type === 'subpage') {
return config.subpages.find((sp) => sp.slug === key)?.password;
}
@@ -80,10 +129,7 @@ function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): strin
/**
* Check if a subpage slug, album ID, or journal entry is password-protected.
*/
export function isProtected(
key: string,
type: 'subpage' | 'album' | 'journal' = 'subpage',
): boolean {
export function isProtected(key: string, type: ProtectedType = 'subpage'): boolean {
return !!findPassword(key, type);
}
@@ -94,7 +140,7 @@ export function isProtected(
export async function authenticate(
key: string,
password: string,
type: 'subpage' | 'album' | 'journal' = 'subpage',
type: ProtectedType = 'subpage',
): Promise<string | null> {
const storedPassword = findPassword(key, type);
if (!storedPassword) return null;
@@ -103,7 +149,7 @@ export async function authenticate(
if (isBcryptHash(storedPassword)) {
console.error(
`\n❌ SECURITY ERROR: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using an outdated bcrypt password hash.\n` +
`\n❌ SECURITY ERROR: ${TYPE_LABELS[type]} "${key}" is using an outdated bcrypt password hash.\n` +
` Bcrypt dependency has been removed to reduce bundle size.\n` +
` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` +
` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`,
@@ -129,7 +175,7 @@ export async function authenticate(
if (isValid) {
const recommendedHash = await generateScryptHash(storedPassword);
console.warn(
`\n⚠️ SECURITY WARNING: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using a plaintext password in gallery.yaml.\n` +
`\n⚠️ SECURITY WARNING: ${TYPE_LABELS[type]} "${key}" is using a plaintext password${type === 'site' ? ' in settings.yaml' : ' in gallery.yaml'}.\n` +
` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
);
}
@@ -138,7 +184,7 @@ export async function authenticate(
if (!isValid) return null;
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
const token = authToken(key, storedPassword, Date.now() + maxAge * 1000);
const token = authToken(tokenKey(key, type), storedPassword, Date.now() + maxAge * 1000);
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`;
@@ -151,7 +197,7 @@ export async function authenticate(
export function isAuthenticated(
key: string,
getCookie: (name: string) => string | undefined,
type: 'subpage' | 'album' | 'journal' = 'subpage',
type: ProtectedType = 'subpage',
): boolean {
const storedPassword = findPassword(key, type);
if (!storedPassword) return true; // not protected
@@ -167,7 +213,7 @@ export function isAuthenticated(
const expiresAt = Number(cookie.slice(0, sep));
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false;
const expected = authToken(key, storedPassword, expiresAt);
const expected = authToken(tokenKey(key, type), storedPassword, expiresAt);
// Constant-time comparison to prevent timing attacks
try {
const a = Buffer.from(cookie, 'utf8');
@@ -178,3 +224,55 @@ export function isAuthenticated(
return false;
}
}
/* ── Site-wide gate ─────────────────────────────────────────────
*
* Opt-in: with no `sitePassword` configured, `findPassword` returns undefined
* and every check below answers "unlocked", so an open portfolio pays nothing
* for the feature existing.
*/
/** True when a site-wide password is configured at all. */
export function isSiteLocked(): boolean {
return isProtected(SITE_AUTH_KEY, 'site');
}
/** Whether the caller may be served the public site. */
export function isSiteUnlocked(getCookie: (name: string) => string | undefined): boolean {
return isAuthenticated(SITE_AUTH_KEY, getCookie, 'site');
}
/** Read one cookie out of a raw `Cookie:` header. */
function readCookieHeader(header: string, name: string): string | undefined {
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
if (part.slice(0, eq).trim() !== name) continue;
try {
return decodeURIComponent(part.slice(eq + 1).trim());
} catch {
return part.slice(eq + 1).trim();
}
}
return undefined;
}
/**
* Route-handler guard. Returns a 401 to send back, or null to carry on.
*
* Route handlers do not go through the root layout, so the page-level gate
* does not cover them: without this, a locked site would still stream every
* photo to anyone holding an asset URL.
*/
export function siteLockResponse(request: Request): Response | null {
const header = request.headers.get('cookie') ?? '';
if (isSiteUnlocked((name) => readCookieHeader(header, name))) return null;
return Response.json(
{ error: 'Site is password-protected' },
// WWW-Authenticate is deliberately omitted: the credential is a form on
// the site, not HTTP auth, and the header would make browsers pop their
// own dialog for an image request.
{ status: 401, headers: { 'Cache-Control': 'no-store' } },
);
}

View File

@@ -1,3 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { getInstallCredentials } from '../install';
@@ -6,6 +8,8 @@ import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
import {
slugify,
resolveExifDisplay,
resolveColorMode,
AppConfig,
AlbumEntryObject,
SubpageConfig,
@@ -159,15 +163,17 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumGrids: Record<string, Partial<GridConfig>> = {};
const albumCoverPositions: Record<string, string> = {};
/** 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,
): string {
): string | null {
if (typeof entry === 'string') {
return validateUuid(entry, context);
}
const [uuid, value] = Object.entries(entry)[0];
const validatedUuid = validateUuid(uuid, context);
if (!validatedUuid) return null;
if (typeof value === 'string') {
albumOverrides[validatedUuid] = value;
@@ -182,10 +188,13 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
// the log naming the cause. Every other ID in this file goes through
// validateUuid; this one was the gap.
if (value.heroImage) {
albumHeroImages[validatedUuid] = validateUuid(
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
@@ -208,9 +217,11 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
// 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}`),
);
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
@@ -239,9 +250,17 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
return validatedUuid;
}
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
processAlbumEntry(entry, 'gallery.yaml albums'),
);
/** Album entries, with the ones whose ID is not a UUID dropped (#517). */
function processAlbumEntries(
entries: Array<string | Record<string, string | AlbumEntryObject>>,
context: string,
): string[] {
return entries
.map((entry) => processAlbumEntry(entry, context))
.filter((id): id is string => id !== null);
}
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
let subpages: SubpageConfig[] = [];
@@ -260,23 +279,19 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
title: sec.title,
slug: slugify(sec.title),
description: sec.description,
albumIds: sec.albums.map((entry) =>
processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`),
),
albumIds: processAlbumEntries(sec.albums, `subpage "${sp.name}" section "${sec.title}"`),
}));
// flat union of all section albums
albumIds = sections.flatMap((s) => s.albumIds);
albumIds = sections!.flatMap((sec) => sec.albumIds);
// Also include any top-level albums (outside sections)
const topLevel = (sp.albums ?? []).map((entry) =>
processAlbumEntry(entry, `subpage "${sp.name}"`),
);
const topLevel = processAlbumEntries(sp.albums ?? [], `subpage "${sp.name}"`);
albumIds = [...topLevel, ...albumIds];
} else {
const albums = sp.albums ?? [];
if (albums.length === 0) {
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
}
albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
albumIds = processAlbumEntries(albums, `subpage "${sp.name}"`);
}
return {
@@ -301,7 +316,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
return {
name,
slug: slugify(name),
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
albumIds: processAlbumEntries(value, `subpage "${name}"`),
enabled: true,
};
}
@@ -312,7 +327,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
slug: slugify(name),
title: sp.title,
subtitle: sp.subtitle,
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
albumIds: processAlbumEntries(albumEntries, `subpage "${name}"`),
password: sp.password,
proofing: sp.proofing,
essayFile: sp.essayFile,
@@ -342,6 +357,15 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
};
}
/** 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
@@ -358,7 +382,15 @@ export function getConfig(): AppConfig {
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
if (!gallery || !apiKey || !apiUrl) {
// 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 },
@@ -369,6 +401,9 @@ export function getConfig(): AppConfig {
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',
@@ -377,8 +412,11 @@ export function getConfig(): AppConfig {
noFollow: true,
},
heroImages: [],
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: '' },
@@ -403,6 +441,8 @@ export function getConfig(): AppConfig {
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
needsSetup: true,
needsCredentials,
needsGallery,
};
}
@@ -433,6 +473,9 @@ export function getConfig(): AppConfig {
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:
@@ -445,11 +488,13 @@ export function getConfig(): AppConfig {
noFollow: settings.seo?.noFollow === true,
},
heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'),
)
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
.map((id) => validateUuid(id, 'gallery.yaml hero'))
.filter((id): id is string => id !== null)
: [],
colorMode: resolveColorMode(settings.mode),
exifOnHover: settings.exifOnHover !== false,
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
grid: {
columns: settings.grid?.columns ?? 3,
gap: settings.grid?.gap ?? 12,
@@ -458,6 +503,7 @@ export function getConfig(): AppConfig {
? settings.grid!.layout
: 'masonry') as GridConfig['layout'],
},
gridGapExplicit: settings.grid?.gap != null,
theme,
footer: settings.footer
? {
@@ -489,7 +535,10 @@ export function getConfig(): AppConfig {
},
protection: settings.protection,
watermark: settings.watermark,
aboutEnabled: settings.about?.enabled !== false,
// 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,
@@ -505,5 +554,7 @@ export function getConfig(): AppConfig {
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
needsSetup: false,
needsCredentials: false,
needsGallery: false,
};
}

View File

@@ -41,13 +41,25 @@ export function loadYaml<T>(filename: string): T | null {
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function validateUuid(id: string, context: string): string {
/**
* An ID from the config, or `null` when it is not a UUID at all.
*
* It used to substitute the all-zeros UUID and log "(Ignored for build/setup)",
* which was untrue twice over: nothing ignored it, and it was not a build-time
* concern. The sentinel travelled on as a real asset ID — encoded into a token,
* requested through /api/image, forwarded to Immich, answered with 400 — so a
* placeholder left in the example file turned the home page into "Something
* went wrong" (#517).
*
* Returning null lets each caller drop the entry instead, which is what
* "ignored" was supposed to mean: one fewer hero image, one album not
* published, rather than a request that cannot succeed.
*/
export function validateUuid(id: string, context: string): string | null {
const trimmed = id.trim();
if (!UUID_REGEX.test(trimmed)) {
// If we are in setup mode or using dummy data, don't crash the build.
// Return a valid dummy UUID so Next.js doesn't panic.
console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`);
return '00000000-0000-0000-0000-000000000000';
console.warn(`Invalid UUID in ${context}: "${trimmed}" — entry dropped.`);
return null;
}
return trimmed;
}

View File

@@ -91,6 +91,58 @@ export interface GridConfig {
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
}
/**
* 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.
*
* `columns` follows the same config as the photo grids (global `settings.yaml`
* < per-subpage override), so "3 columns" in the admin means three columns
* everywhere. `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;
}
function clamp(value: number, 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;
return Math.min(max, Math.max(min, Math.round(value)));
}
export interface AppConfig {
immich: { apiUrl: string; apiKey: string };
authSecret: string;
@@ -108,8 +160,19 @@ export interface AppConfig {
noFollow: boolean;
};
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;
@@ -144,7 +207,28 @@ export interface AppConfig {
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;
@@ -217,7 +301,10 @@ export interface SettingsYaml {
noIndex?: boolean;
noFollow?: boolean;
};
/** 'dark' (default), 'light' or 'auto' — the visitor's starting colour mode. */
mode?: ColorMode;
exifOnHover?: boolean;
exif?: ExifDisplayYaml;
map?: boolean;
transitions?: boolean;
scrollToTop?: boolean;
@@ -252,6 +339,8 @@ export interface SettingsYaml {
disableRightClick?: boolean;
disableImageDrag?: boolean;
};
/** Puts the whole public site behind one password. `SITE_PASSWORD` overrides it. */
sitePassword?: string;
watermark?: {
enabled?: boolean;
text?: string;
@@ -261,6 +350,106 @@ export interface SettingsYaml {
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));
}
export function slugify(name: string): string {
return name
.toLowerCase()

View File

@@ -17,6 +17,7 @@ export interface Env {
TRUSTED_PROXY_HOPS: number;
WEBHOOK_SECRET?: string;
ADMIN_PASSWORD?: string;
SITE_PASSWORD?: string;
INSTALL_CONTENT_DIR?: string;
}
@@ -108,6 +109,7 @@ function parseEnv(): Env {
TRUSTED_PROXY_HOPS: trustedProxyHops,
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET || undefined,
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
SITE_PASSWORD: process.env.SITE_PASSWORD || undefined,
INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined,
};
}

33
lib/exif.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Client-safe EXIF formatting. No `fs`, no crypto — `lib/urls.ts` cannot be
* imported from a client component, and the lightbox needs this.
*/
/**
* The camera as one readable name.
*
* EXIF carries maker and model separately, and most makers repeat themselves in
* the model: Immich reports `make: "LEICA CAMERA AG"` with `model: "LEICA Q3"`,
* Nikon `"NIKON CORPORATION"` with `"NIKON Z 6"`. Printing both gave
* "LEICA CAMERA AG LEICA Q3", which is wrong twice over — it says the brand
* twice, and it was long enough to collide with its own label in the info
* panel (#514).
*
* Only the maker's first word is compared, and only against whole words of the
* model, so "OM Digital Solutions" is dropped from "OM-1" without a stray "om"
* inside another word passing for a brand. Makers the model does not name —
* Sony's "ILCE-7M4", say — keep their prefix.
*/
export function formatCamera(make?: string | null, model?: string | null): string {
const cleanModel = model?.trim() ?? '';
const cleanMake = make?.trim() ?? '';
if (!cleanModel) return cleanMake;
if (!cleanMake) return cleanModel;
const brand = cleanMake.split(/\s+/)[0].toLowerCase();
const lowerModel = cleanModel.toLowerCase();
const namesBrand = lowerModel.split(/[\s\-_]+/).includes(brand) || lowerModel.startsWith(brand);
return namesBrand ? cleanModel : `${cleanMake} ${cleanModel}`;
}

55
lib/i18n/index.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Visitor-facing internationalisation.
*
* Deliberately not a framework: `settings.yaml: lang` picks one of the
* dictionaries in `lib/i18n/locales/`, and every visitor-facing string is read
* off that object. No message extraction, no runtime loader, no route prefixes
* — a self-hosted portfolio is served in exactly one language.
*
* This module is **client-safe** (no `fs`). Server components read the language
* from the config via `lib/i18n/server.ts`; client components take the resolved
* locale from `components/I18nProvider.tsx` and look the dictionary up here.
* Both dictionaries are small enough that shipping them together beats a
* dynamic import.
*/
import { en } from './locales/en';
import { de } from './locales/de';
/** Locales with a dictionary. Anything else falls back to English. */
export const SUPPORTED_LOCALES = ['en', 'de'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en';
/** The shape every dictionary must implement — English is the reference. */
export type Dictionary = typeof en;
const DICTIONARIES: Record<Locale, Dictionary> = { en, de };
/**
* Map a `settings.yaml: lang` value onto a locale we have strings for.
*
* Region subtags are accepted (`de-AT` → `de`), unknown languages fall back to
* English. The raw value still reaches `<html lang>` — a French deployment gets
* `lang="fr"` for screen readers and an English UI, which beats claiming to be
* English.
*/
export function resolveLocale(lang?: string | null): Locale {
if (!lang) return DEFAULT_LOCALE;
const base = lang.trim().toLowerCase().split(/[-_]/)[0];
return (SUPPORTED_LOCALES as readonly string[]).includes(base)
? (base as Locale)
: DEFAULT_LOCALE;
}
/** Dictionary for a locale (already resolved). */
export function getDictionary(locale: Locale): Dictionary {
return DICTIONARIES[locale] ?? DICTIONARIES[DEFAULT_LOCALE];
}
/** Convenience: resolve a raw `lang` string and return its dictionary. */
export function dictionaryFor(lang?: string | null): Dictionary {
return getDictionary(resolveLocale(lang));
}

178
lib/i18n/locales/de.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* German dictionary.
*
* Typed as `Dictionary` (derived from `en.ts`), so a key added to English but
* forgotten here fails the type-check rather than falling through to English
* at runtime.
*/
import type { Dictionary } from '../index';
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
export const de: Dictionary = {
dateLocale: 'de-DE',
nav: {
home: 'Start',
about: 'Über mich',
map: 'Karte',
journal: 'Journal',
skipToContent: 'Zum Inhalt springen',
},
common: {
backTo: (label: string) => `Zurück zu ${label}`,
backToGallery: 'Zurück zur Galerie',
backToJournal: 'Zurück zum Journal',
home: 'Start',
photos: (n: number) => plural(n, 'Foto', 'Fotos'),
albums: (n: number) => plural(n, 'Album', 'Alben'),
collections: (n: number) => plural(n, 'Sammlung', 'Sammlungen'),
loadingGallery: 'Galerie wird geladen',
loadingPhotos: 'Fotos werden geladen',
},
home: {
enter: 'Eintreten',
},
error: {
notFoundTitle: 'Nicht gefunden',
notFoundText: 'Diese Seite existiert nicht, oder das Album ist nicht mehr veröffentlicht.',
errorTitle: 'Etwas ist schiefgelaufen',
errorText:
'Diese Seite konnte gerade nicht geladen werden. Das ist meist vorübergehend — bitte gleich noch einmal versuchen.',
siteErrorText:
'Diese Website konnte gerade nicht geladen werden. Das ist meist vorübergehend — bitte gleich noch einmal versuchen.',
tryAgain: 'Erneut versuchen',
reference: (digest: string) => `Referenz: ${digest}`,
},
theme: {
switchToLight: 'Zum hellen Modus wechseln',
switchToDark: 'Zum dunklen Modus wechseln',
scrollToTop: 'Nach oben',
},
password: {
subtitle: 'Diese Galerie ist passwortgeschützt.',
siteSubtitle: 'Diese Website ist passwortgeschützt.',
placeholder: 'Passwort eingeben',
submit: 'Öffnen',
verifying: 'Wird geprüft…',
incorrect: 'Falsches Passwort. Bitte erneut versuchen.',
failed: 'Passwort konnte nicht geprüft werden. Bitte später erneut versuchen.',
},
about: {
kicker: 'Über mich',
title: 'Über mich',
metaTitle: (name: string) => `Über mich — ${name}`,
portraitAlt: 'Porträt',
gear: 'Ausrüstung',
},
map: {
title: 'Karte',
kicker: 'Wo',
subtitle: 'Wo die Fotos entstanden sind',
loading: 'Karte wird geladen…',
loadFailed: (status: number) => `Kartendaten konnten nicht geladen werden (${status})`,
initFailed: 'Karte konnte nicht initialisiert werden',
},
subpage: {
collectionKicker: (index: string) => `${index} — Sammlung`,
sectionsNav: 'Abschnitte',
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
},
journal: {
title: 'Journal',
kicker: 'Geschichten & Essays',
subtitle: 'Fotoessays, visuelle Geschichten und Notizen von unterwegs.',
description: 'Fotoessays, Reisegeschichten und Journale hinter den Kulissen.',
entryDescription: 'Journal-Eintrag',
empty: 'Noch keine Journal-Einträge veröffentlicht.',
readStory: 'Geschichte lesen →',
minRead: (n: number) => `${n} Min. Lesezeit`,
draft: 'Entwurf',
by: (author: string) => `von ${author}`,
notFound: 'Nicht gefunden',
},
lightbox: {
viewer: 'Bildansicht',
close: 'Schließen',
closeTitle: 'Schließen (Esc)',
previous: 'Vorheriges Foto',
previousTitle: 'Vorheriges Foto (Pfeil links)',
next: 'Nächstes Foto',
nextTitle: 'Nächstes Foto (Pfeil rechts)',
toggleInfo: 'Fotoinfos ein-/ausblenden',
toggleInfoTitle: 'Fotoinfos ein-/ausblenden (i)',
hideInfo: 'Infos ausblenden',
info: 'Infos',
loading: 'Wird geladen...',
camera: 'Kamera',
lens: 'Objektiv',
focalLength: 'Brennweite',
aperture: 'Blende',
shutter: 'Belichtungszeit',
iso: 'ISO',
location: 'Ort',
noExif: 'Keine EXIF-Daten vorhanden',
shortcuts: 'Tastaturkürzel',
shortcutNavigate: 'Vorheriges / nächstes Foto',
shortcutInfo: 'Fotoinfos',
shortcutFullscreen: 'Vollbild',
shortcutExitFullscreen: 'Vollbild verlassen',
shortcutList: 'Diese Liste',
shortcutClose: 'Ansicht schließen',
},
proofing: {
addFavorite: 'Zur Auswahl hinzufügen',
removeFavorite: 'Aus Auswahl entfernen',
addToFavorites: 'Zur Auswahl hinzufügen',
removeFromFavorites: 'Aus Auswahl entfernen',
saved: 'Gemerkt',
favorite: 'Merken',
showAll: 'Alle anzeigen',
selected: (n: number) => `❤️ ${n} ausgewählt`,
shareExport: 'Teilen & Export',
modalTitle: (n: number) => `❤️ Auswahl (${n})`,
closeModal: 'Dialog schließen',
intro: (n: number) =>
`Du hast ${plural(n, 'Foto', 'Fotos')} ausgewählt. Wähle unten eine Export-Option, um deine Auswahl zu teilen:`,
copyLink: 'Teilbaren Link kopieren',
linkCopied: 'Link kopiert!',
copyList: 'Textliste kopieren (#1, #2...)',
listCopied: 'Liste kopiert!',
sendEmail: '✉️ E-Mail an den Fotografen',
clearSelection: 'Auswahl löschen',
confirmClear: 'Die gesamte Auswahl löschen?',
listEmpty: (albumName: string) => `${albumName}: Keine Fotos ausgewählt.`,
listSummary: (albumName: string, count: number, indices: string) =>
`${albumName} — Ausgewählte Fotos (${count}): ${indices}`,
mailSubject: (n: number) => `Fotoauswahl (${n} Fotos)`,
mailBody: (list: string, url: string) =>
`Hallo,\n\nhier ist meine Fotoauswahl:\n\n${list}\n\nLink zur Auswahl: ${url}\n\nViele Grüße,`,
},
legal: {
navLabel: 'Impressum',
title: 'Impressum',
subtitle: 'Angaben gemäß § 5 TMG',
address: 'Anschrift',
contact: 'Kontakt',
email: 'E-Mail',
phone: 'Telefon',
taxSection: 'Steuernummer',
taxId: 'Steuernummer',
vatId: 'Umsatzsteuer-ID',
extraInfo: 'Weitere Informationen',
source: 'Quelle: Erstellt mit dem Impressum-Generator von eRecht24.',
},
};

178
lib/i18n/locales/en.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* English dictionary — the reference locale.
*
* Every other locale implements the `Dictionary` type derived from this file,
* so adding a key here is a type error in `de.ts` until it is translated.
* Values are plain strings, or functions when a count or a name is interpolated
* (a locale is free to pluralise differently — German "Alben" is not "Albums").
*/
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
export const en = {
/** Passed to `toLocaleDateString` for visitor-facing dates. */
dateLocale: 'en-US',
nav: {
home: 'Home',
about: 'About',
map: 'Map',
journal: 'Journal',
skipToContent: 'Skip to content',
},
common: {
backTo: (label: string) => `Back to ${label}`,
backToGallery: 'Back to Gallery',
backToJournal: 'Back to Journal',
home: 'Home',
photos: (n: number) => plural(n, 'photo', 'photos'),
albums: (n: number) => plural(n, 'album', 'albums'),
collections: (n: number) => plural(n, 'collection', 'collections'),
loadingGallery: 'Loading gallery',
loadingPhotos: 'Loading photos',
},
home: {
enter: 'Enter',
},
error: {
notFoundTitle: 'Not found',
notFoundText: 'This page does not exist, or the album is no longer published.',
errorTitle: 'Something went wrong',
errorText:
'This page could not be loaded right now. It is usually temporary — try again in a moment.',
siteErrorText:
'This site could not be loaded right now. It is usually temporary — try again in a moment.',
tryAgain: 'Try again',
reference: (digest: string) => `Reference: ${digest}`,
},
theme: {
switchToLight: 'Switch to light mode',
switchToDark: 'Switch to dark mode',
scrollToTop: 'Scroll to top',
},
password: {
subtitle: 'This gallery is password-protected.',
siteSubtitle: 'This site is password-protected.',
placeholder: 'Enter password',
submit: 'Enter',
verifying: 'Verifying…',
incorrect: 'Incorrect password. Please try again.',
failed: 'Unable to verify password. Please try again later.',
},
about: {
kicker: 'About',
title: 'About',
metaTitle: (name: string) => `About — ${name}`,
portraitAlt: 'Portrait',
gear: 'Gear',
},
map: {
title: 'Map',
kicker: 'Where',
subtitle: 'Where the photos were taken',
loading: 'Loading map…',
loadFailed: (status: number) => `Failed to load map data (${status})`,
initFailed: 'Failed to initialize map',
},
subpage: {
collectionKicker: (index: string) => `${index} — Collection`,
sectionsNav: 'Sections',
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
},
journal: {
title: 'Journal',
kicker: 'Stories & Essays',
subtitle: 'Photo essays, visual stories, and field notes.',
description: 'Photo essays, travel stories, and behind-the-scenes journals.',
entryDescription: 'Journal entry',
empty: 'No journal entries published yet.',
readStory: 'Read Story →',
minRead: (n: number) => `${n} min read`,
draft: 'Draft',
by: (author: string) => `by ${author}`,
notFound: 'Not Found',
},
lightbox: {
viewer: 'Image viewer',
close: 'Close',
closeTitle: 'Close (Esc)',
previous: 'Previous photo',
previousTitle: 'Previous photo (Left arrow)',
next: 'Next photo',
nextTitle: 'Next photo (Right arrow)',
toggleInfo: 'Toggle photo info',
toggleInfoTitle: 'Toggle photo info (i)',
hideInfo: 'Hide Info',
info: 'Info',
loading: 'Loading...',
camera: 'Camera',
lens: 'Lens',
focalLength: 'Focal Length',
aperture: 'Aperture',
shutter: 'Shutter',
iso: 'ISO',
location: 'Location',
noExif: 'No EXIF data available',
shortcuts: 'Keyboard shortcuts',
shortcutNavigate: 'Previous / next photo',
shortcutInfo: 'Photo info',
shortcutFullscreen: 'Fullscreen',
shortcutExitFullscreen: 'Leave fullscreen',
shortcutList: 'This list',
shortcutClose: 'Close the viewer',
},
proofing: {
addFavorite: 'Add favorite',
removeFavorite: 'Remove favorite',
addToFavorites: 'Add to favorites',
removeFromFavorites: 'Remove from favorites',
saved: 'Saved',
favorite: 'Favorite',
showAll: 'Show All',
selected: (n: number) => `❤️ ${n} Selected`,
shareExport: 'Share & Export',
modalTitle: (n: number) => `❤️ Selection (${n})`,
closeModal: 'Close modal',
intro: (n: number) =>
`You have selected ${plural(n, 'photo', 'photos')}. Choose an export option below to share your selection:`,
copyLink: 'Copy Shareable Link',
linkCopied: 'Link Copied!',
copyList: 'Copy Text List (#1, #2...)',
listCopied: 'List Copied!',
sendEmail: '✉️ Send Email to Photographer',
clearSelection: 'Clear Selection',
confirmClear: 'Clear all selected favorites?',
listEmpty: (albumName: string) => `${albumName}: No photos selected.`,
listSummary: (albumName: string, count: number, indices: string) =>
`${albumName} — Selected Photos (${count}): ${indices}`,
mailSubject: (n: number) => `Photo Selection (${n} items)`,
mailBody: (list: string, url: string) =>
`Hello,\n\nHere is my photo selection:\n\n${list}\n\nShare Link: ${url}\n\nBest regards,`,
},
legal: {
navLabel: 'Legal Notice',
title: 'Legal Notice',
subtitle: 'Information pursuant to § 5 TMG',
address: 'Address',
contact: 'Contact',
email: 'Email',
phone: 'Phone',
taxSection: 'Tax Information',
taxId: 'Tax number',
vatId: 'VAT ID',
extraInfo: 'Additional Information',
source: 'Source: Created with the Impressum generator by eRecht24.',
},
};

19
lib/i18n/server.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Server-side entry point for i18n. Separate from `lib/i18n/index.ts` because
* it reaches into `lib/config`, which pulls in `fs` — importing this from a
* client component breaks the build (same split as `lib/journal.ts` vs
* `lib/admin/journal-service.ts`).
*/
import { getConfigOrNull } from '@/lib/config';
import { resolveLocale, getDictionary, type Dictionary, type Locale } from './index';
/** Locale of the current deployment, resolved from `settings.yaml: lang`. */
export function getLocale(): Locale {
return resolveLocale(getConfigOrNull()?.lang);
}
/** Dictionary for the current deployment. */
export function getServerDictionary(): Dictionary {
return getDictionary(getLocale());
}

View File

@@ -124,6 +124,7 @@ export interface SubpageSummary {
class ImmichClient {
private hasLoggedAlbums = false;
private hasWarnedNoCredentials = false;
private pendingAlbumsPromise: Promise<ImmichAlbum[]> | null = null;
private pendingAlbumPromises = new Map<string, Promise<ImmichAlbum | null>>();
private pendingAssetPromises = new Map<string, Promise<ImmichAsset | null>>();
@@ -132,6 +133,30 @@ class ImmichClient {
return getConfig();
}
/**
* Whether a request to Immich is possible at all.
*
* Deliberately *not* `needsSetup`: a deployment with credentials but no
* gallery.yaml can talk to Immich perfectly well, and the admin panel depends
* on that to browse albums before the file exists. Gating on `needsSetup`
* made every call return null before the fetch, so `ping()` reported the
* server as disconnected without a single line in the log (#507).
*/
private get hasCredentials(): boolean {
const { apiUrl, apiKey } = this.config.immich;
return !!apiUrl && !!apiKey;
}
/** Says once why nothing is being requested, rather than failing in silence. */
private warnNoCredentials(what: string): void {
if (this.hasWarnedNoCredentials) return;
this.hasWarnedNoCredentials = true;
console.warn(
`[Immich] No API URL or API key configured — skipping ${what} and every ` +
'later request. Set IMMICH_API_URL and IMMICH_API_KEY, or run the setup wizard at /install.',
);
}
/** Cache write that carries the configured stale window. */
private cacheSet<T>(key: string, data: T): void {
cache.set(key, data, this.config.cacheTtl, this.config.staleMaxAge);
@@ -155,7 +180,10 @@ class ImmichClient {
}
private async request<T>(endpoint: string, body?: unknown): Promise<T | null> {
if (this.config.needsSetup) return null;
if (!this.hasCredentials) {
this.warnNoCredentials(endpoint);
return null;
}
const url = `${this.config.immich.apiUrl}${endpoint}`;
@@ -234,7 +262,7 @@ class ImmichClient {
contentRange: string | null;
status: 200 | 206;
} | null> {
if (this.config.needsSetup || !this.config.immich.apiUrl) return null;
if (!this.hasCredentials) return null;
const endpoint = `/assets/${encodeURIComponent(assetId)}/video/playback`;
const url = `${this.config.immich.apiUrl}${endpoint}`;
@@ -303,7 +331,7 @@ class ImmichClient {
assetId: string,
size: ImageSize = 'preview',
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
if (this.config.needsSetup || !this.config.immich.apiUrl) return null;
if (!this.hasCredentials) return null;
const endpoint =
size === 'original'
@@ -433,7 +461,15 @@ class ImmichClient {
console.log('─'.repeat(80) + '\n');
}
this.cacheSet(cacheKey, filtered);
// Not while the install is unfinished. The dummy setup config carries an
// empty allowlist, so `filtered` is [] no matter what Immich returned —
// and caching that outlives the wizard that fixes it, because
// invalidateAll() runs in the install route's own module instance
// (Next bundles each route separately; see lib/install.ts). The gallery
// then looks empty until the server restarts.
if (!this.config.needsSetup) {
this.cacheSet(cacheKey, filtered);
}
return filtered;
} catch (error) {
return this.staleOrThrow<ImmichAlbum[]>(cacheKey, error, 'album list');
@@ -776,9 +812,12 @@ class ImmichClient {
try {
const res = await this.request<{ res: string }>('/server/ping');
return !!res;
} catch {
} catch (error) {
// "Is Immich reachable?" — unreachable is the answer, not an exception.
// Both callers (/api/health, /api/admin/status) render this as a status.
// It is still logged: a red badge in the admin panel with nothing in the
// log leaves the operator guessing (#507).
console.warn('[Immich] Ping failed:', error);
return false;
}
}

View File

@@ -218,6 +218,8 @@ export interface InstallInput {
siteSubtitle?: string;
theme?: string;
albums?: string[];
/** Asset IDs for the home page carousel, usually the covers of `albums`. */
hero?: string[];
adminPassword?: string;
}
@@ -245,7 +247,11 @@ export async function completeInstall(input: InstallInput): Promise<void> {
await fs.promises.mkdir(dir, { recursive: true });
const gallery: GalleryYaml = {
hero: [],
// A photography portfolio whose front page carries no photograph reads as
// unfinished, and the wizard already knows which albums were picked (#518).
// The caller seeds these from their Immich covers; an empty list is still
// valid, and the admin panel can change them afterwards.
hero: (input.hero ?? []).filter((id) => typeof id === 'string'),
albums: (input.albums ?? []).filter((id) => typeof id === 'string'),
};

View File

@@ -10,7 +10,7 @@
*/
import { NextRequest } from 'next/server';
import { getConfig } from './config';
import { env } from './env';
/** Bucket key used when the client cannot be identified with any confidence. */
const UNIDENTIFIED = 'unknown';
@@ -67,7 +67,14 @@ export function __resetProxyWarningForTests(): void {
* control the whole header and no parsing strategy can help.
*/
export function getClientIp(request: NextRequest): string {
const hops = getConfig().trustedProxyHops;
// Read straight from the environment rather than through getConfig(): the
// config parses gallery.yaml and resolves AUTH_SECRET, which throws in
// production when neither an env var nor install.json carries one. That made
// every rate-limited route — including the install wizard's own API, whose
// job is to *create* that secret — answer 500 on a container started with no
// environment at all (#519). Rate limiting has no business depending on
// whether the site's content config can be read.
const hops = env.TRUSTED_PROXY_HOPS;
// @ts-expect-error - Next.js 15+ removed request.ip from types but hosting platforms still populate it
const directIp = request.ip as string | undefined;