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:
@@ -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.
|
||||
|
||||
107
lib/__tests__/album-meta.test.ts
Normal file
107
lib/__tests__/album-meta.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
26
lib/__tests__/color-mode.test.ts
Normal file
26
lib/__tests__/color-mode.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
60
lib/__tests__/cover-grid.test.ts
Normal file
60
lib/__tests__/cover-grid.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
218
lib/__tests__/doctor.test.ts
Normal file
218
lib/__tests__/doctor.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
51
lib/__tests__/example-files.test.ts
Normal file
51
lib/__tests__/example-files.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
lib/__tests__/exif-camera.test.ts
Normal file
38
lib/__tests__/exif-camera.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
92
lib/__tests__/exif-display.test.ts
Normal file
92
lib/__tests__/exif-display.test.ts
Normal 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
110
lib/__tests__/i18n.test.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
123
lib/__tests__/immich-setup-state.test.ts
Normal file
123
lib/__tests__/immich-setup-state.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
lib/__tests__/install-hero.test.ts
Normal file
71
lib/__tests__/install-hero.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
191
lib/__tests__/site-password.test.ts
Normal file
191
lib/__tests__/site-password.test.ts
Normal 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 = [];
|
||||
});
|
||||
});
|
||||
37
lib/__tests__/watermark-opacity.test.ts
Normal file
37
lib/__tests__/watermark-opacity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
347
lib/admin/doctor.ts
Normal 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
64
lib/albumMeta.ts
Normal 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()}`;
|
||||
}
|
||||
124
lib/auth.ts
124
lib/auth.ts
@@ -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' } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
33
lib/exif.ts
Normal 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
55
lib/i18n/index.ts
Normal 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
178
lib/i18n/locales/de.ts
Normal 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
178
lib/i18n/locales/en.ts
Normal 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
19
lib/i18n/server.ts
Normal 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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user