release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
53
lib/__tests__/asset-selection.test.ts
Normal file
53
lib/__tests__/asset-selection.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { canSelectMore, matchesQuery, toggleSelection } from '../admin/assetSelection';
|
||||
|
||||
describe('toggleSelection (#602)', () => {
|
||||
it('appends in click order and removes on a second toggle', () => {
|
||||
let s = toggleSelection([], 'a');
|
||||
s = toggleSelection(s, 'b');
|
||||
s = toggleSelection(s, 'c');
|
||||
expect(s).toEqual(['a', 'b', 'c']);
|
||||
expect(toggleSelection(s, 'b')).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('refuses assets the caller already uses', () => {
|
||||
expect(toggleSelection(['a'], 'x', { disabled: new Set(['x']) })).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('stops at max but still lets you deselect', () => {
|
||||
const full = ['a', 'b'];
|
||||
expect(toggleSelection(full, 'c', { max: 2 })).toEqual(['a', 'b']);
|
||||
expect(toggleSelection(full, 'a', { max: 2 })).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('replaces the pick when max is 1', () => {
|
||||
expect(toggleSelection(['a'], 'b', { max: 1 })).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('does not mutate its input', () => {
|
||||
const s = ['a'];
|
||||
toggleSelection(s, 'b');
|
||||
expect(s).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSelectMore', () => {
|
||||
it('is unlimited without max, bounded with it, and always open for a single slot', () => {
|
||||
expect(canSelectMore(['a', 'b', 'c'])).toBe(true);
|
||||
expect(canSelectMore(['a'], 2)).toBe(true);
|
||||
expect(canSelectMore(['a', 'b'], 2)).toBe(false);
|
||||
expect(canSelectMore(['a'], 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesQuery', () => {
|
||||
const asset = { originalFileName: 'IMG_4021.HEIC' };
|
||||
it('matches a case-insensitive filename fragment', () => {
|
||||
expect(matchesQuery(asset, 'img_40')).toBe(true);
|
||||
expect(matchesQuery(asset, ' 4021 ')).toBe(true);
|
||||
expect(matchesQuery(asset, 'DSC')).toBe(false);
|
||||
});
|
||||
it('matches everything for an empty query', () => {
|
||||
expect(matchesQuery(asset, ' ')).toBe(true);
|
||||
});
|
||||
});
|
||||
71
lib/__tests__/auth-page.test.ts
Normal file
71
lib/__tests__/auth-page.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Content page passwords (#722): their own cookie, `lb_auth_page_<slug>`,
|
||||
* through the same authenticate()/isAuthenticated() path as every other gate,
|
||||
* and a token that no other gate of the same slug and password accepts.
|
||||
*/
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||
standaloneAlbums: [],
|
||||
albumPasswords: {},
|
||||
subpages: [{ name: 'Pricing', slug: 'pricing', albumIds: [], password: 'shared' }],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/pages-service', () => ({
|
||||
readPageSync: (slug: string) =>
|
||||
slug === 'pricing' || slug === 'open'
|
||||
? {
|
||||
slug,
|
||||
rawMarkdown: '',
|
||||
parsed: {
|
||||
frontmatter: slug === 'pricing' ? { password: 'shared' } : {},
|
||||
blocks: [],
|
||||
referencedAssetIds: [],
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
import { authenticate, isAuthenticated, isProtected } from '@/lib/auth';
|
||||
|
||||
function cookieFrom(setCookie: string): [string, string] {
|
||||
const [pair] = setCookie.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
return [pair.slice(0, eq), pair.slice(eq + 1)];
|
||||
}
|
||||
|
||||
describe('content page passwords', () => {
|
||||
it('knows which pages are protected', () => {
|
||||
expect(isProtected('pricing', 'page')).toBe(true);
|
||||
expect(isProtected('open', 'page')).toBe(false);
|
||||
expect(isProtected('missing', 'page')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets lb_auth_page_<slug> and accepts it back', async () => {
|
||||
const setCookie = await authenticate('pricing', 'shared', 'page');
|
||||
expect(setCookie).not.toBeNull();
|
||||
const [name, value] = cookieFrom(setCookie!);
|
||||
expect(name).toBe('lb_auth_page_pricing');
|
||||
expect(isAuthenticated('pricing', (n) => (n === name ? value : undefined), 'page')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a wrong password', async () => {
|
||||
expect(await authenticate('pricing', 'nope', 'page')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not accept a subpage token of the same slug and password', async () => {
|
||||
const [, subpageValue] = cookieFrom((await authenticate('pricing', 'shared', 'subpage'))!);
|
||||
expect(
|
||||
isAuthenticated(
|
||||
'pricing',
|
||||
(n) => (n === 'lb_auth_page_pricing' ? subpageValue : undefined),
|
||||
'page',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,13 @@ vi.mock('@/lib/config', () => ({
|
||||
albumIds: ['00000000-0000-0000-0000-000000000002'],
|
||||
// no password
|
||||
},
|
||||
{
|
||||
// Taken offline with `enabled: false`, and asks for nothing.
|
||||
name: 'Offline',
|
||||
slug: 'offline',
|
||||
albumIds: ['00000000-0000-0000-0000-000000000006'],
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
name: 'Hashed',
|
||||
slug: 'hashed',
|
||||
@@ -34,6 +41,13 @@ vi.mock('@/lib/config', () => ({
|
||||
password:
|
||||
'scrypt:0123456789abcdef0123456789abcdef:4850eef18a09612ef11fdcd943bf64ce98d0eca2b0f677f9c0c51d1f648b8a101cf7ea36b6f94cfc05236b2fda6fffabd0929dc7e14c07626522fba68bf26de5',
|
||||
},
|
||||
{
|
||||
// A leftover bcrypt hash: refused outright, whatever the attempt.
|
||||
name: 'Legacy',
|
||||
slug: 'legacy',
|
||||
albumIds: ['00000000-0000-0000-0000-000000000007'],
|
||||
password: '$2b$10$abcdefghijklmnopqrstuuPjJ7x1b0c9vX8rZ0Yx0mZ1n2o3p4q5r',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
@@ -129,6 +143,41 @@ describe('password hashing does not stall the process', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A key with an scrypt hash took ~50ms to answer, every other key ~2ms — the
|
||||
* early return skipped hashing. Identical reply bodies did not help: the clock
|
||||
* alone told a prober which pages, albums and journal entries are locked,
|
||||
* drafts included. Every attempt now runs exactly one scrypt. Counting the
|
||||
* calls keeps the test independent of how fast the machine is.
|
||||
*/
|
||||
describe('authenticate — one scrypt per attempt, whatever the key', () => {
|
||||
const attempts: [string, () => Promise<string | null>][] = [
|
||||
['scrypt hash, right password', () => authenticate('hashed', 'scrypted-pass')],
|
||||
['scrypt hash, wrong password', () => authenticate('hashed', 'wrong')],
|
||||
['plaintext, right password', () => authenticate('private', 'secret123')],
|
||||
['plaintext, wrong password', () => authenticate('private', 'wrong')],
|
||||
['key without a password', () => authenticate('public', 'anything')],
|
||||
['unknown key', () => authenticate('no-such-page', 'anything')],
|
||||
['unknown journal entry', () => authenticate('no-such-entry', 'x', 'journal')],
|
||||
['unknown album', () => authenticate('00000000-0000-0000-0000-00000000dead', 'x', 'album')],
|
||||
['legacy bcrypt hash', () => authenticate('legacy', 'anything')],
|
||||
];
|
||||
|
||||
it.each(attempts)('%s', async (_label, attempt) => {
|
||||
const scrypt = vi.spyOn(crypto, 'scrypt');
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
await attempt();
|
||||
expect(scrypt).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
scrypt.mockRestore();
|
||||
error.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('returns true when a valid cookie is present', async () => {
|
||||
// First authenticate to get the expected token
|
||||
@@ -249,6 +298,11 @@ describe('isAlbumReachable', () => {
|
||||
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
|
||||
});
|
||||
|
||||
// `enabled: false` takes the subpage offline; its albums have no route left.
|
||||
it('refuses an album whose only subpage is offline', () => {
|
||||
expect(isAlbumReachable('00000000-0000-0000-0000-000000000006', none)).toBe(false);
|
||||
});
|
||||
|
||||
it('still refuses an album that carries its own password', async () => {
|
||||
const subpageOnly = await cookieFor('private', 'secret123');
|
||||
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// Mock env.ts so importing config.ts doesn't trigger Zod validation
|
||||
vi.mock('@/lib/env', () => ({
|
||||
@@ -17,47 +20,109 @@ vi.mock('@/lib/secret', () => ({
|
||||
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
|
||||
}));
|
||||
|
||||
// Replace the filesystem-backed YAML layer. The real loadYaml() already
|
||||
// revalidates by mtime, so "the file changed on disk" is modelled by the
|
||||
// mock returning different content.
|
||||
vi.mock('@/lib/config/parser', () => ({
|
||||
loadYaml: vi.fn(),
|
||||
clearYamlCache: vi.fn(),
|
||||
validateUuid: (id: string) => id,
|
||||
}));
|
||||
// The real YAML layer, spied on: how often it is asked is how often the
|
||||
// config is derived.
|
||||
vi.mock('@/lib/config/parser', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/config/parser')>();
|
||||
return { ...actual, loadYaml: vi.fn(actual.loadYaml) };
|
||||
});
|
||||
|
||||
import { getConfig, invalidateConfigCache } from '@/lib/config';
|
||||
import { loadYaml } from '@/lib/config/parser';
|
||||
|
||||
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
function mockYamlFiles(settings: Record<string, unknown>) {
|
||||
vi.mocked(loadYaml).mockImplementation((filename: string) => {
|
||||
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
|
||||
if (filename === 'settings.yaml') return settings;
|
||||
return null;
|
||||
});
|
||||
let dir: string;
|
||||
let mtime = Date.now() / 1000;
|
||||
|
||||
/** Write a content file with a distinct mtime, the way a save from another worker lands. */
|
||||
function write(name: string, body: string) {
|
||||
const file = path.join(dir, 'content', name);
|
||||
fs.writeFileSync(file, body);
|
||||
mtime += 10;
|
||||
fs.utimesSync(file, mtime, mtime);
|
||||
}
|
||||
|
||||
describe('getConfig() in production', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-config-cache-'));
|
||||
fs.mkdirSync(path.join(dir, 'content'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(dir);
|
||||
write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n`);
|
||||
write('settings.yaml', 'title: Old Title\n');
|
||||
invalidateConfigCache();
|
||||
vi.mocked(loadYaml).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('picks up settings.yaml changes without invalidateConfigCache()', () => {
|
||||
// An admin save may run in a different worker/process than page
|
||||
// rendering, so this worker never sees invalidateConfigCache().
|
||||
// getConfig() must delegate freshness to the mtime-checked YAML layer
|
||||
// instead of short-circuiting on a stale in-memory copy.
|
||||
mockYamlFiles({ title: 'Old Title' });
|
||||
// getConfig() must notice the file changed instead of short-circuiting
|
||||
// on a stale in-memory copy.
|
||||
expect(getConfig().siteTitle).toBe('Old Title');
|
||||
|
||||
mockYamlFiles({ title: 'New Title' });
|
||||
write('settings.yaml', 'title: New Title\n');
|
||||
expect(getConfig().siteTitle).toBe('New Title');
|
||||
});
|
||||
|
||||
it('picks up gallery.yaml changes without invalidateConfigCache()', () => {
|
||||
expect(getConfig().albums).toEqual([ALBUM_ID]);
|
||||
|
||||
const other = '22222222-2222-2222-2222-222222222222';
|
||||
write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n - "${other}"\n`);
|
||||
expect(getConfig().albums).toEqual([ALBUM_ID, other]);
|
||||
});
|
||||
|
||||
it('notices about.md appearing', () => {
|
||||
expect(getConfig().aboutEnabled).toBe(false);
|
||||
|
||||
write('about.md', '---\nname: Someone\n---\n');
|
||||
expect(getConfig().aboutEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('notices install.json credentials appearing', () => {
|
||||
// env supplies the URL; the file is still watched.
|
||||
write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'file-key' }));
|
||||
getConfig();
|
||||
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2);
|
||||
|
||||
write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'other' }));
|
||||
getConfig();
|
||||
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
// Every image request and every page render call getConfig() many times
|
||||
// over. Deriving the whole config each time cost ~150µs a call; the files
|
||||
// did not change in between.
|
||||
it('derives the config once while its files are unchanged', () => {
|
||||
for (let i = 0; i < 100; i++) getConfig();
|
||||
|
||||
// gallery.yaml and settings.yaml, once — not 200 reads.
|
||||
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('derives again after invalidateConfigCache()', () => {
|
||||
getConfig();
|
||||
invalidateConfigCache();
|
||||
getConfig();
|
||||
|
||||
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('hands every caller its own copy', () => {
|
||||
const first = getConfig();
|
||||
first.albums.push('mutated');
|
||||
first.siteTitle = 'mutated';
|
||||
|
||||
const second = getConfig();
|
||||
expect(second.albums).toEqual([ALBUM_ID]);
|
||||
expect(second.siteTitle).toBe('Old Title');
|
||||
});
|
||||
});
|
||||
|
||||
59
lib/__tests__/config-default-title.test.ts
Normal file
59
lib/__tests__/config-default-title.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// No SITE_TITLE, so the title has to come from the locale.
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
IMMICH_API_URL: 'http://localhost:2283',
|
||||
IMMICH_API_KEY: 'test-key',
|
||||
SITE_TITLE: '',
|
||||
SITE_SUBTITLE: '',
|
||||
CACHE_TTL: 300,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config/parser', () => ({
|
||||
loadYaml: vi.fn(),
|
||||
clearYamlCache: vi.fn(),
|
||||
validateUuid: (id: string) => id,
|
||||
}));
|
||||
|
||||
import { getConfig, invalidateConfigCache } from '@/lib/config';
|
||||
import { loadYaml } from '@/lib/config/parser';
|
||||
|
||||
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
function mockYamlFiles(settings: Record<string, unknown>) {
|
||||
vi.mocked(loadYaml).mockImplementation((filename: string) => {
|
||||
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
|
||||
if (filename === 'settings.yaml') return settings;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// With neither settings.yaml `title` nor SITE_TITLE set, the site used to be
|
||||
// called "Gallery" in every language (#696).
|
||||
describe('default site title', () => {
|
||||
beforeEach(() => invalidateConfigCache());
|
||||
|
||||
it('uses the configured locale', () => {
|
||||
mockYamlFiles({ lang: 'de' });
|
||||
expect(getConfig().siteTitle).toBe('Galerie');
|
||||
expect(getConfig().seo.title).toBe('Galerie');
|
||||
});
|
||||
|
||||
it('falls back to English for an unsupported locale', () => {
|
||||
mockYamlFiles({ lang: 'ja' });
|
||||
expect(getConfig().siteTitle).toBe('Gallery');
|
||||
});
|
||||
|
||||
it('keeps an explicit title', () => {
|
||||
mockYamlFiles({ lang: 'de', title: 'Mein Folio' });
|
||||
expect(getConfig().siteTitle).toBe('Mein Folio');
|
||||
});
|
||||
});
|
||||
35
lib/__tests__/config-memo-tracing.test.ts
Normal file
35
lib/__tests__/config-memo-tracing.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* configInputs() stats a list of paths it builds at runtime. Turbopack cannot
|
||||
* resolve a path that arrives through a variable, so it traces the whole
|
||||
* project into `.next/standalone` — app/, docs/, e2e/, the real content files —
|
||||
* and the Docker image copies all of it (#753 did this, ~70 MB). The ignore
|
||||
* comment is the documented opt-out; the stat only reads an mtime, and the
|
||||
* files themselves are loaded (and traced) by loadYaml() and readInstallFile().
|
||||
*
|
||||
* The build prints a warning when this regresses, but nothing fails, so the
|
||||
* check lives here.
|
||||
*/
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'config', 'index.ts'), 'utf8');
|
||||
|
||||
/** The body of configInputs(), up to the next top-level declaration. */
|
||||
const body = source.match(/function configInputs\(\)[\s\S]*?\n}\n/)?.[0] ?? '';
|
||||
|
||||
describe('config memo key and output file tracing', () => {
|
||||
it('finds configInputs()', () => {
|
||||
expect(body).toContain('statSync');
|
||||
});
|
||||
|
||||
it('marks every filesystem call in configInputs() as ignored by Turbopack', () => {
|
||||
const calls = body.match(/fs\.\w+\([^)]*/g) ?? [];
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const unmarked = calls.filter(
|
||||
(call) => !/^fs\.\w+\(\s*\/\*\s*turbopackIgnore: true\s*\*\//.test(call),
|
||||
);
|
||||
expect(unmarked).toEqual([]);
|
||||
});
|
||||
});
|
||||
194
lib/__tests__/content-version.test.ts
Normal file
194
lib/__tests__/content-version.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'fs/promises';
|
||||
import {
|
||||
ABSENT_VERSION,
|
||||
VersionConflictError,
|
||||
assertVersion,
|
||||
baseVersionFrom,
|
||||
fileVersion,
|
||||
serializeContentWrite,
|
||||
versionOf,
|
||||
} from '../admin/contentVersion';
|
||||
|
||||
/**
|
||||
* Concurrent-edit detection (#601): a save carrying the version its editor
|
||||
* loaded is refused when the file changed since; a save without one is
|
||||
* written as before.
|
||||
*
|
||||
* The services resolve content/ from process.cwd() at import time, so each
|
||||
* test points cwd at a fresh temp directory and re-imports them.
|
||||
*/
|
||||
let root: string;
|
||||
let contentDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-version-'));
|
||||
contentDir = path.join(root, 'content');
|
||||
await mkdir(path.join(contentDir, 'journal'), { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** By name: the services are re-imported per test, so their error class is a different copy. */
|
||||
const conflict = expect.objectContaining({ name: 'VersionConflictError' });
|
||||
|
||||
const put = (ifMatch?: string) =>
|
||||
new Request('http://localhost/x', {
|
||||
method: 'PUT',
|
||||
headers: ifMatch === undefined ? {} : { 'If-Match': ifMatch },
|
||||
});
|
||||
|
||||
describe('contentVersion helpers', () => {
|
||||
it('versions content by its bytes, and a missing file as absent', async () => {
|
||||
expect(versionOf('a')).toBe(versionOf(Buffer.from('a')));
|
||||
expect(versionOf('a')).not.toBe(versionOf('b'));
|
||||
expect(versionOf(null)).toBe(ABSENT_VERSION);
|
||||
await expect(fileVersion(path.join(root, 'nope'))).resolves.toBe(ABSENT_VERSION);
|
||||
await writeFile(path.join(root, 'f'), 'a');
|
||||
await expect(fileVersion(path.join(root, 'f'))).resolves.toBe(versionOf('a'));
|
||||
});
|
||||
|
||||
it('reads If-Match in bare, quoted and weak form; * and absent mean no check', () => {
|
||||
expect(baseVersionFrom(put('abc'))).toBe('abc');
|
||||
expect(baseVersionFrom(put('"abc"'))).toBe('abc');
|
||||
expect(baseVersionFrom(put('W/"abc"'))).toBe('abc');
|
||||
expect(baseVersionFrom(put('*'))).toBeUndefined();
|
||||
expect(baseVersionFrom(put())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('assertVersion passes on a match or no base, and throws with the current version', async () => {
|
||||
const file = path.join(root, 'f');
|
||||
await writeFile(file, 'now');
|
||||
await expect(assertVersion(file, versionOf('now'))).resolves.toBeUndefined();
|
||||
await expect(assertVersion(file, undefined)).resolves.toBeUndefined();
|
||||
const err = await assertVersion(file, versionOf('before')).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(VersionConflictError);
|
||||
expect(err.currentVersion).toBe(versionOf('now'));
|
||||
});
|
||||
|
||||
it('runs queued writes one at a time, and a failure does not stall the queue', async () => {
|
||||
const order: string[] = [];
|
||||
const slow = serializeContentWrite(async () => {
|
||||
order.push('a:start');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
order.push('a:end');
|
||||
});
|
||||
const failing = serializeContentWrite(async () => {
|
||||
order.push('b');
|
||||
throw new Error('boom');
|
||||
});
|
||||
const after = serializeContentWrite(async () => order.push('c'));
|
||||
await Promise.allSettled([slow, failing, after]);
|
||||
await expect(failing).rejects.toThrow('boom');
|
||||
expect(order).toEqual(['a:start', 'a:end', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('yaml-service', () => {
|
||||
it('writes settings.yaml when the base version matches, and refuses when it does not', async () => {
|
||||
const service = await import('../admin/yaml-service');
|
||||
const settingsPath = path.join(contentDir, 'settings.yaml');
|
||||
await writeFile(settingsPath, 'title: One\n');
|
||||
|
||||
const loaded = await service.readSettingsYamlVersioned();
|
||||
expect(loaded.data).toEqual({ title: 'One' });
|
||||
|
||||
// Another tab saves first.
|
||||
const second = await service.writeSettingsYaml({ title: 'Two' }, loaded.version);
|
||||
expect(second).toBe(versionOf(await readFile(settingsPath)));
|
||||
|
||||
// This tab still holds the first version.
|
||||
await expect(service.writeSettingsYaml({ title: 'Three' }, loaded.version)).rejects.toEqual(
|
||||
expect.objectContaining({ name: 'VersionConflictError', currentVersion: second }),
|
||||
);
|
||||
expect(await service.readSettingsYaml()).toEqual({ title: 'Two' });
|
||||
});
|
||||
|
||||
it('still writes without a base version (older clients)', async () => {
|
||||
const service = await import('../admin/yaml-service');
|
||||
await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n');
|
||||
await service.writeGalleryYaml({ hero: ['x'] } as never);
|
||||
expect(await service.readGalleryYaml()).toEqual({ hero: ['x'] });
|
||||
});
|
||||
|
||||
it('treats a file created since an absent load as a conflict', async () => {
|
||||
const service = await import('../admin/yaml-service');
|
||||
const loaded = await service.readGalleryYamlVersioned();
|
||||
expect(loaded.version).toBe(ABSENT_VERSION);
|
||||
await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n');
|
||||
await expect(service.writeGalleryYaml({ hero: [] } as never, loaded.version)).rejects.toEqual(
|
||||
conflict,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
|
||||
|
||||
describe('journal-service saveJournalEntry', () => {
|
||||
it('saves on a matching version and refuses a stale one', async () => {
|
||||
const service = await import('../admin/journal-service');
|
||||
const file = path.join(contentDir, 'journal', 'trip.md');
|
||||
await writeFile(file, md('One'));
|
||||
const base = versionOf(md('One'));
|
||||
|
||||
await expect(service.saveJournalEntry('trip', md('Two'), { baseVersion: base })).resolves.toBe(
|
||||
versionOf(md('Two')),
|
||||
);
|
||||
await expect(
|
||||
service.saveJournalEntry('trip', md('Three'), { baseVersion: base }),
|
||||
).rejects.toEqual(conflict);
|
||||
await expect(readFile(file, 'utf8')).resolves.toBe(md('Two'));
|
||||
// No base: written as before.
|
||||
await service.saveJournalEntry('trip', md('Four'));
|
||||
await expect(readFile(file, 'utf8')).resolves.toBe(md('Four'));
|
||||
});
|
||||
|
||||
it('checks a rename against the source entry and leaves both files alone on conflict', async () => {
|
||||
const service = await import('../admin/journal-service');
|
||||
const dir = path.join(contentDir, 'journal');
|
||||
await writeFile(path.join(dir, 'old.md'), md('Changed elsewhere'));
|
||||
|
||||
const err = await service
|
||||
.saveJournalEntry('new', md('Mine'), {
|
||||
baseVersion: versionOf(md('Loaded')),
|
||||
fromSlug: 'old',
|
||||
})
|
||||
.catch((e) => e);
|
||||
expect(err).toEqual(conflict);
|
||||
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['old.md']);
|
||||
|
||||
await service.saveJournalEntry('new', md('Mine'), {
|
||||
baseVersion: versionOf(md('Changed elsewhere')),
|
||||
fromSlug: 'old',
|
||||
});
|
||||
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['new.md']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pages-service savePage', () => {
|
||||
it('saves on a matching version, refuses a stale one, and renames', async () => {
|
||||
const service = await import('../admin/pages-service');
|
||||
const dir = path.join(contentDir, 'pages');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, 'faq.md'), md('FAQ'));
|
||||
|
||||
await expect(
|
||||
service.savePage('faq', md('Stale'), { baseVersion: versionOf(md('Other')) }),
|
||||
).rejects.toEqual(conflict);
|
||||
await expect(readFile(path.join(dir, 'faq.md'), 'utf8')).resolves.toBe(md('FAQ'));
|
||||
|
||||
await service.savePage('help', md('Help'), {
|
||||
baseVersion: versionOf(md('FAQ')),
|
||||
fromSlug: 'faq',
|
||||
});
|
||||
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['help.md']);
|
||||
});
|
||||
});
|
||||
93
lib/__tests__/content-write-guard.test.ts
Normal file
93
lib/__tests__/content-write-guard.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import { writeFile as namedWriteFile, mkdtemp, readFile, rm } from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { guardedContentDir, takeContentWriteViolations } from '../../vitest.content-guard';
|
||||
|
||||
/**
|
||||
* The unit suite used to write a real journal entry into the checkout's
|
||||
* content/ — the live site's directory in a deployment — and leave a
|
||||
* .deleted.bak behind on every run. vitest.content-guard.ts refuses such
|
||||
* writes; these tests pin that it catches each way code writes a file, and
|
||||
* that it leaves reads and temp directories alone.
|
||||
*/
|
||||
const repoContent = path.resolve(__dirname, '..', '..', 'content');
|
||||
const target = path.join(repoContent, `.content-guard-test-${process.pid}`);
|
||||
|
||||
function expectBlocked(write: () => unknown) {
|
||||
expect(write).toThrow(/real content\/ directory/);
|
||||
expect(fs.existsSync(target)).toBe(false);
|
||||
expect(takeContentWriteViolations()).toHaveLength(1);
|
||||
}
|
||||
|
||||
async function expectRejected(write: () => Promise<unknown>) {
|
||||
await expect(write()).rejects.toThrow(/real content\/ directory/);
|
||||
expect(fs.existsSync(target)).toBe(false);
|
||||
expect(takeContentWriteViolations()).toHaveLength(1);
|
||||
}
|
||||
|
||||
describe('content write guard', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(os.tmpdir(), 'folio-guard-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
takeContentWriteViolations();
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('protects the checkout content/ directory', () => {
|
||||
expect(guardedContentDir).toBe(repoContent);
|
||||
});
|
||||
|
||||
it('rejects fs.promises writes, the named fs/promises import included', async () => {
|
||||
await expectRejected(() => fs.promises.writeFile(target, 'x'));
|
||||
await expectRejected(() => namedWriteFile(target, 'x'));
|
||||
await expectRejected(() => fs.promises.mkdir(target, { recursive: true }));
|
||||
await expectRejected(() => fs.promises.open(target, 'a+'));
|
||||
});
|
||||
|
||||
it('blocks sync, callback and stream writes', () => {
|
||||
expectBlocked(() => fs.writeFileSync(target, 'x'));
|
||||
expectBlocked(() => fs.writeFile(target, 'x', () => {}));
|
||||
expectBlocked(() => fs.mkdirSync(target, { recursive: true }));
|
||||
expectBlocked(() => fs.appendFileSync(target, 'x'));
|
||||
expectBlocked(() => fs.createWriteStream(target));
|
||||
});
|
||||
|
||||
it('blocks opening for writing and moving, copying or removing a file', () => {
|
||||
const outside = path.join(tmp, 'file');
|
||||
fs.writeFileSync(outside, 'x');
|
||||
expectBlocked(() => fs.openSync(target, 'w'));
|
||||
expectBlocked(() => fs.renameSync(outside, target));
|
||||
expectBlocked(() => fs.copyFileSync(outside, target));
|
||||
expectBlocked(() => fs.unlinkSync(target));
|
||||
expectBlocked(() => fs.rmSync(target, { force: true }));
|
||||
});
|
||||
|
||||
it('still blocks when a test points process.cwd() elsewhere', () => {
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tmp);
|
||||
expectBlocked(() => fs.writeFileSync(target, 'x'));
|
||||
});
|
||||
|
||||
it('records a blocked write even when the caller swallows the error', async () => {
|
||||
await fs.promises.writeFile(target, 'x').catch(() => {});
|
||||
expect(takeContentWriteViolations()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows reads from content/ and writes anywhere else', async () => {
|
||||
const example = path.join(repoContent, 'gallery.yaml.example');
|
||||
await expect(readFile(example, 'utf8')).resolves.toContain('albums');
|
||||
fs.closeSync(fs.openSync(example, 'r'));
|
||||
|
||||
const elsewhere = path.join(tmp, 'content', 'journal', 'entry.md');
|
||||
fs.mkdirSync(path.dirname(elsewhere), { recursive: true });
|
||||
await namedWriteFile(elsewhere, 'ok');
|
||||
await expect(readFile(elsewhere, 'utf8')).resolves.toBe('ok');
|
||||
expect(takeContentWriteViolations()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
checkContact,
|
||||
checkLegal,
|
||||
checkPrivacy,
|
||||
checkContentPages,
|
||||
checkSettingValues,
|
||||
worstLevel,
|
||||
} from '../admin/doctor';
|
||||
|
||||
@@ -444,3 +446,58 @@ describe('checkPrivacy', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkContentPages (#722)', () => {
|
||||
it('stays silent without pages or references', () => {
|
||||
expect(checkContentPages({ menuRefs: [], pages: [], collisions: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('is ok when every reference has a file and no slug is taken', () => {
|
||||
const f = checkContentPages({
|
||||
menuRefs: ['pricing'],
|
||||
pages: ['pricing', 'faq'],
|
||||
collisions: [],
|
||||
});
|
||||
expect(f?.level).toBe('ok');
|
||||
expect(f?.id).toBe('content-pages');
|
||||
});
|
||||
|
||||
it('warns about a menu reference without a file, naming it', () => {
|
||||
const f = checkContentPages({
|
||||
menuRefs: ['pricing', 'gone'],
|
||||
pages: ['pricing'],
|
||||
collisions: [],
|
||||
});
|
||||
expect(f?.level).toBe('warn');
|
||||
expect(f?.detail).toContain('"gone"');
|
||||
});
|
||||
|
||||
it('reports a slug collision as an error', () => {
|
||||
const f = checkContentPages({
|
||||
menuRefs: [],
|
||||
pages: ['travel'],
|
||||
collisions: [{ slug: 'travel', reason: '/travel is already a subpage.' }],
|
||||
});
|
||||
expect(f?.level).toBe('error');
|
||||
expect(f?.detail).toContain('/travel is already a subpage.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSettingValues (QA A-14)', () => {
|
||||
it('is ok when every checked value is used as written', () => {
|
||||
expect(checkSettingValues([]).level).toBe('ok');
|
||||
});
|
||||
|
||||
it('warns and names the fields the site replaces', () => {
|
||||
const f = checkSettingValues([{ field: 'theme.accent' }, { field: 'grid.columns' }]);
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.title).toBe('2 settings are ignored');
|
||||
expect(f.detail).toContain('accent colour, grid columns');
|
||||
});
|
||||
|
||||
it('points at the settings section of the first field', () => {
|
||||
expect(checkSettingValues([{ field: 'url' }]).settingsSection).toBe('seo');
|
||||
expect(checkSettingValues([{ field: 'grid.gap' }]).settingsSection).toBe('grid');
|
||||
expect(checkSettingValues([{ field: 'theme.accent' }]).settingsSection).toBe('theme');
|
||||
});
|
||||
});
|
||||
|
||||
63
lib/__tests__/envLocks.test.ts
Normal file
63
lib/__tests__/envLocks.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ENV_LOCKS, keepLockedValues, resolveEnvLocks } from '../admin/envLocks';
|
||||
|
||||
describe('resolveEnvLocks (#605)', () => {
|
||||
it('locks nothing when no overriding variable is set', () => {
|
||||
expect(resolveEnvLocks({})).toEqual({});
|
||||
expect(resolveEnvLocks({ SITE_PASSWORD: ' ', CONTACT_NOTIFY_URL: undefined })).toEqual({});
|
||||
});
|
||||
|
||||
it('maps each set variable to its settings path, by name only', () => {
|
||||
const locks = resolveEnvLocks({
|
||||
SITE_PASSWORD: 'hunter2',
|
||||
CONTACT_NOTIFY_URL: 'https://ntfy.sh/secret-topic',
|
||||
});
|
||||
expect(locks).toEqual({
|
||||
sitePassword: 'SITE_PASSWORD',
|
||||
'contact.notifyUrl': 'CONTACT_NOTIFY_URL',
|
||||
});
|
||||
expect(JSON.stringify(locks)).not.toContain('hunter2');
|
||||
expect(JSON.stringify(locks)).not.toContain('secret-topic');
|
||||
});
|
||||
|
||||
it('does not lock title, subtitle or url: settings.yaml wins over those variables', () => {
|
||||
const locks = resolveEnvLocks({ SITE_TITLE: 'T', SITE_SUBTITLE: 'S', SITE_URL: 'https://x' });
|
||||
expect(locks).toEqual({});
|
||||
expect(Object.keys(ENV_LOCKS)).not.toContain('title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepLockedValues (#605)', () => {
|
||||
const locks = { sitePassword: 'SITE_PASSWORD', 'contact.notifyUrl': 'CONTACT_NOTIFY_URL' };
|
||||
|
||||
it('puts the stored value back over an edited locked field', () => {
|
||||
const out = keepLockedValues(
|
||||
{ title: 'New', sitePassword: 'changed', contact: { enabled: true, notifyUrl: 'https://b' } },
|
||||
{ sitePassword: 'scrypt:a:b', contact: { notifyUrl: 'https://a' } },
|
||||
locks,
|
||||
);
|
||||
expect(out).toEqual({
|
||||
title: 'New',
|
||||
sitePassword: 'scrypt:a:b',
|
||||
contact: { enabled: true, notifyUrl: 'https://a' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a locked value the file never had', () => {
|
||||
const out = keepLockedValues(
|
||||
{ sitePassword: 'new', contact: { notifyUrl: 'https://b' } },
|
||||
{},
|
||||
locks,
|
||||
);
|
||||
expect(out).toEqual({ contact: {} });
|
||||
});
|
||||
|
||||
it('leaves unlocked fields alone and does not mutate the input', () => {
|
||||
const incoming = { sitePassword: 'new' };
|
||||
expect(keepLockedValues(incoming, { sitePassword: 'old' }, {})).toEqual({
|
||||
sitePassword: 'new',
|
||||
});
|
||||
keepLockedValues(incoming, { sitePassword: 'old' }, locks);
|
||||
expect(incoming.sitePassword).toBe('new');
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,7 @@ describe('resolveEssayFile', () => {
|
||||
});
|
||||
|
||||
it('locks a password-protected entry without a cookie', () => {
|
||||
expect(resolveEssayFile('locked', anonymous)).toEqual({ status: 'locked', title: 'Locked' });
|
||||
expect(resolveEssayFile('locked', anonymous)).toEqual({ status: 'locked' });
|
||||
});
|
||||
|
||||
it('keeps the entry locked for the admin, like /journal/<slug> does', () => {
|
||||
|
||||
@@ -219,3 +219,160 @@ describe('plural helpers', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Count strings at the CLDR boundaries. French puts 0 in the singular ("0 photo"),
|
||||
* the other five use the singular for exactly 1 — so a shared `n === 1` helper
|
||||
* was wrong for French, and a hard-coded plural noun ("(1 items)") was wrong for
|
||||
* everyone at 1. Italian "foto"/"album" and the Dutch/German participles are
|
||||
* invariable, which the table spells out rather than skips.
|
||||
*/
|
||||
type Counted = (d: Dictionary, n: number) => string;
|
||||
const COUNTED = {
|
||||
photos: (d, n) => d.common.photos(n),
|
||||
albums: (d, n) => d.common.albums(n),
|
||||
collections: (d, n) => d.common.collections(n),
|
||||
selected: (d, n) => d.proofing.selected(n),
|
||||
mailSubject: (d, n) => d.proofing.mailSubject(n),
|
||||
downloadsLeft: (d, n) => d.proofSession.downloadsLeft(n),
|
||||
} satisfies Record<string, Counted>;
|
||||
type CountedKey = keyof typeof COUNTED;
|
||||
|
||||
const PLURAL_TABLE: [string, Dictionary, Record<CountedKey, [string, string, string]>][] = [
|
||||
[
|
||||
'en',
|
||||
en,
|
||||
{
|
||||
photos: ['0 photos', '1 photo', '2 photos'],
|
||||
albums: ['0 albums', '1 album', '2 albums'],
|
||||
collections: ['0 collections', '1 collection', '2 collections'],
|
||||
selected: ['❤️ 0 Selected', '❤️ 1 Selected', '❤️ 2 Selected'],
|
||||
mailSubject: [
|
||||
'Photo Selection (0 items)',
|
||||
'Photo Selection (1 item)',
|
||||
'Photo Selection (2 items)',
|
||||
],
|
||||
downloadsLeft: ['0 downloads left', '1 download left', '2 downloads left'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'de',
|
||||
de,
|
||||
{
|
||||
photos: ['0 Fotos', '1 Foto', '2 Fotos'],
|
||||
albums: ['0 Alben', '1 Album', '2 Alben'],
|
||||
collections: ['0 Sammlungen', '1 Sammlung', '2 Sammlungen'],
|
||||
selected: ['❤️ 0 ausgewählt', '❤️ 1 ausgewählt', '❤️ 2 ausgewählt'],
|
||||
mailSubject: ['Fotoauswahl (0 Fotos)', 'Fotoauswahl (1 Foto)', 'Fotoauswahl (2 Fotos)'],
|
||||
downloadsLeft: ['Noch 0 Downloads', 'Noch 1 Download', 'Noch 2 Downloads'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'fr',
|
||||
fr,
|
||||
{
|
||||
photos: ['0 photo', '1 photo', '2 photos'],
|
||||
albums: ['0 album', '1 album', '2 albums'],
|
||||
collections: ['0 collection', '1 collection', '2 collections'],
|
||||
selected: ['❤️ 0 sélectionnée', '❤️ 1 sélectionnée', '❤️ 2 sélectionnées'],
|
||||
mailSubject: [
|
||||
'Sélection de photos (0)',
|
||||
'Sélection de photos (1)',
|
||||
'Sélection de photos (2)',
|
||||
],
|
||||
downloadsLeft: [
|
||||
'0 téléchargement restant',
|
||||
'1 téléchargement restant',
|
||||
'2 téléchargements restants',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'es',
|
||||
es,
|
||||
{
|
||||
photos: ['0 fotos', '1 foto', '2 fotos'],
|
||||
albums: ['0 álbumes', '1 álbum', '2 álbumes'],
|
||||
collections: ['0 colecciones', '1 colección', '2 colecciones'],
|
||||
selected: ['❤️ 0 seleccionadas', '❤️ 1 seleccionada', '❤️ 2 seleccionadas'],
|
||||
mailSubject: ['Selección de fotos (0)', 'Selección de fotos (1)', 'Selección de fotos (2)'],
|
||||
downloadsLeft: ['0 descargas restantes', '1 descarga restante', '2 descargas restantes'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'it',
|
||||
itIT,
|
||||
{
|
||||
photos: ['0 foto', '1 foto', '2 foto'],
|
||||
albums: ['0 album', '1 album', '2 album'],
|
||||
collections: ['0 collezioni', '1 collezione', '2 collezioni'],
|
||||
selected: ['❤️ 0 selezionate', '❤️ 1 selezionata', '❤️ 2 selezionate'],
|
||||
mailSubject: ['Selezione di foto (0)', 'Selezione di foto (1)', 'Selezione di foto (2)'],
|
||||
downloadsLeft: ['0 download rimasti', '1 download rimasto', '2 download rimasti'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'nl',
|
||||
nl,
|
||||
{
|
||||
photos: ['0 foto’s', '1 foto', '2 foto’s'],
|
||||
albums: ['0 albums', '1 album', '2 albums'],
|
||||
collections: ['0 collecties', '1 collectie', '2 collecties'],
|
||||
selected: ['❤️ 0 geselecteerd', '❤️ 1 geselecteerd', '❤️ 2 geselecteerd'],
|
||||
mailSubject: ['Fotoselectie (0)', 'Fotoselectie (1)', 'Fotoselectie (2)'],
|
||||
downloadsLeft: ['Nog 0 downloads', 'Nog 1 download', 'Nog 2 downloads'],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe('plural forms at 0, 1 and 2', () => {
|
||||
it('cover every supported locale', () => {
|
||||
expect(PLURAL_TABLE.map(([code]) => code).sort()).toEqual([...SUPPORTED_LOCALES].sort());
|
||||
});
|
||||
|
||||
describe.each(PLURAL_TABLE)('%s', (_code, dict, expected) => {
|
||||
it.each(Object.keys(COUNTED) as CountedKey[])('%s', (key) => {
|
||||
expect([0, 1, 2].map((n) => COUNTED[key](dict, n))).toEqual(expected[key]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['en', en, ['0 days.', '1 day.', '2 days.']],
|
||||
['de', de, ['0 Tagen gelöscht.', '1 Tag gelöscht.', '2 Tagen gelöscht.']],
|
||||
['fr', fr, ['0 jour.', '1 jour.', '2 jours.']],
|
||||
['es', es, ['0 días.', '1 día.', '2 días.']],
|
||||
['it', itIT, ['0 giorni.', '1 giorno.', '2 giorni.']],
|
||||
['nl', nl, ['0 dagen verwijderd.', '1 dag verwijderd.', '2 dagen verwijderd.']],
|
||||
] as const)('contact retention days in %s', (_code, dict, endings) => {
|
||||
const got = [0, 1, 2].map((n) => dict.contact.privacy(n));
|
||||
got.forEach((text, i) => expect(text.endsWith(` ${endings[i]}`), text).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe('German terminology', () => {
|
||||
// The carousel said "Diashow", the lightbox's shortcut panel and live
|
||||
// region "Diaschau" for the same feature.
|
||||
it('calls the slideshow "Diashow" everywhere', () => {
|
||||
const strings = [
|
||||
de.home.pauseSlideshow,
|
||||
de.home.playSlideshow,
|
||||
de.lightbox.shortcutSlideshow,
|
||||
de.lightbox.shortcutSlideshowRunning(5),
|
||||
de.lightbox.slideshowStopped,
|
||||
de.lightbox.slideshowRunning(5),
|
||||
];
|
||||
for (const s of strings) expect(s).toContain('Diashow');
|
||||
});
|
||||
|
||||
it('leaves no "Diaschau" in any string', () => {
|
||||
const walk = (value: unknown): string[] =>
|
||||
typeof value === 'string'
|
||||
? [value]
|
||||
: typeof value === 'function'
|
||||
? [String((value as (n: number) => string)(2))]
|
||||
: value && typeof value === 'object'
|
||||
? Object.values(value).flatMap(walk)
|
||||
: [];
|
||||
expect(walk(de).filter((s) => s.includes('Diaschau'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
73
lib/__tests__/image-url-per-tier.test.ts
Normal file
73
lib/__tests__/image-url-per-tier.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getImgProps } from 'next/dist/shared/lib/get-img-props';
|
||||
import { imageConfigDefault } from 'next/dist/shared/lib/image-config';
|
||||
import immichLoader from '../immichLoader';
|
||||
import { canonicalImageUrl, resolveImageSize } from '../imageSize';
|
||||
|
||||
/**
|
||||
* The proxy answers every width inside an Immich tier with the same bytes, but
|
||||
* each distinct URL is its own `immutable` cache entry. next/image used to
|
||||
* write a different `?w=` into every srcset candidate and the lightbox asked
|
||||
* for the bare URL, so a grid tile and the same photo in the lightbox — or a
|
||||
* tile before and after a rotation — downloaded the identical preview twice.
|
||||
*/
|
||||
|
||||
const PREVIEW = '/api/image/v2:tok?size=preview';
|
||||
const THUMB = '/api/image/v2:tok?size=thumbnail';
|
||||
|
||||
const ALL_WIDTHS = [...imageConfigDefault.deviceSizes, ...imageConfigDefault.imageSizes];
|
||||
|
||||
function srcSetUrls(src: string, sizes: string): string[] {
|
||||
const { props } = getImgProps(
|
||||
{ src, alt: '', fill: true, sizes },
|
||||
{
|
||||
defaultLoader: immichLoader,
|
||||
imgConf: { ...imageConfigDefault, loader: 'custom', loaderFile: './lib/immichLoader.ts' },
|
||||
showAltText: false,
|
||||
blurComplete: false,
|
||||
},
|
||||
);
|
||||
return (props.srcSet ?? '').split(', ').map((candidate) => candidate.split(' ')[0]);
|
||||
}
|
||||
|
||||
const tierOf = (url: string) => {
|
||||
const params = new URLSearchParams(url.slice(url.indexOf('?') + 1));
|
||||
return resolveImageSize(params.get('size'), params.get('w'));
|
||||
};
|
||||
|
||||
describe('one image URL per asset and tier', () => {
|
||||
it.each([
|
||||
['PhotoGrid', '(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw'],
|
||||
['SubpageGridView', '(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw'],
|
||||
['album hero', '100vw'],
|
||||
['home hero', '(max-width: 640px) 100vw, 50vw'],
|
||||
['essay', '(max-width: 1100px) 100vw, 1100px'],
|
||||
])('every %s srcset candidate is the URL the lightbox opens', (_name, sizes) => {
|
||||
const urls = srcSetUrls(PREVIEW, sizes);
|
||||
expect(urls.length).toBeGreaterThan(3);
|
||||
expect(new Set(urls)).toEqual(new Set([canonicalImageUrl(PREVIEW)]));
|
||||
});
|
||||
|
||||
it('collapses a thumbnail-ceiling image to one URL as well', () => {
|
||||
expect(new Set(srcSetUrls(THUMB, '25vw'))).toEqual(new Set([canonicalImageUrl(THUMB)]));
|
||||
});
|
||||
|
||||
it('asks the proxy for the same rendition the raw width did', () => {
|
||||
for (const size of ['thumbnail', 'preview', 'original', null]) {
|
||||
const src = size ? `/api/image/v2:tok?size=${size}` : '/api/image/v2:tok';
|
||||
for (const w of [...ALL_WIDTHS, 1, 250, 251, 1440, 1441, 99999]) {
|
||||
expect(tierOf(canonicalImageUrl(src, w))).toBe(resolveImageSize(size, String(w)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the cache buster and writes a width next/image accepts', () => {
|
||||
expect(canonicalImageUrl('/api/image/v2:tok?size=preview&v=7', 640)).toBe(
|
||||
'/api/image/v2:tok?size=preview&v=7&w=1440',
|
||||
);
|
||||
expect(canonicalImageUrl('https://cdn.example.com/api/image/v2:tok?size=thumbnail', 96)).toBe(
|
||||
'https://cdn.example.com/api/image/v2:tok?size=thumbnail&w=250',
|
||||
);
|
||||
expect(immichLoader({ src: PREVIEW, width: 400 })).not.toBe(PREVIEW);
|
||||
});
|
||||
});
|
||||
230
lib/__tests__/immich-follows-content.test.ts
Normal file
230
lib/__tests__/immich-follows-content.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Public pages follow admin saves (A-2) and the page builder's album order
|
||||
* (A-6).
|
||||
*
|
||||
* An admin save lands in another module instance (Next bundles each route
|
||||
* separately), so its immich.invalidateAll() never reached the cache the
|
||||
* pages read. The album list was cached with the allowlist and the
|
||||
* gallery.yaml titles and descriptions already applied, and the public site
|
||||
* kept serving the old ones for up to CACHE_TTL. These tests use the real
|
||||
* getConfig() on a temp content directory: the only signal a save leaves is
|
||||
* the new mtime of gallery.yaml.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
IMMICH_API_URL: 'http://immich.test',
|
||||
IMMICH_API_KEY: 'test-key',
|
||||
SITE_TITLE: 'Test Gallery',
|
||||
SITE_SUBTITLE: '',
|
||||
CACHE_TTL: 300,
|
||||
STALE_MAX_AGE: 86_400,
|
||||
IMMICH_TIMEOUT_MS: 15_000,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
|
||||
}));
|
||||
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { cache } from '@/lib/cache';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
const A = '11111111-1111-4111-8111-111111111111';
|
||||
const B = '22222222-2222-4222-8222-222222222222';
|
||||
const C = '33333333-3333-4333-8333-333333333333';
|
||||
const NEW = '44444444-4444-4444-8444-444444444444';
|
||||
|
||||
let dir: string;
|
||||
let mtime = Date.now() / 1000;
|
||||
let immichAlbums: { id: string; albumName: string; description: string; assetCount: number }[];
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
/** Write a content file with a distinct mtime, the way a save from another instance lands. */
|
||||
function write(name: string, body: string) {
|
||||
const file = path.join(dir, 'content', name);
|
||||
fs.writeFileSync(file, body);
|
||||
mtime += 10;
|
||||
fs.utimesSync(file, mtime, mtime);
|
||||
}
|
||||
|
||||
const json = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const listRequests = () =>
|
||||
fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/albums?shared=true')).length;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-immich-follows-'));
|
||||
fs.mkdirSync(path.join(dir, 'content'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(dir);
|
||||
write('settings.yaml', 'title: Test\n');
|
||||
invalidateConfigCache();
|
||||
cache.clear();
|
||||
immichAlbums = [
|
||||
// Immich answers in its own order (last updated first, say).
|
||||
{ id: C, albumName: 'Gamma', description: 'from Immich', assetCount: 3 },
|
||||
{ id: A, albumName: 'Alpha', description: '', assetCount: 1 },
|
||||
{ id: B, albumName: 'Beta', description: '', assetCount: 2 },
|
||||
];
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (String(url).endsWith('/albums?shared=true')) return json(immichAlbums);
|
||||
return { ok: false, status: 404, headers: { get: () => 'application/json' } };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('public album data follows a gallery.yaml save (A-2)', () => {
|
||||
it('shows a new title and description on the next call, without asking Immich again', async () => {
|
||||
write('gallery.yaml', `albums:\n - "${A}"\n - "${B}"\n`);
|
||||
const before = await immich.getAlbums();
|
||||
expect(before.map((a) => a.albumName)).toEqual(['Alpha', 'Beta']);
|
||||
expect(listRequests()).toBe(1);
|
||||
|
||||
// The admin saves in another module instance: no invalidation reaches us.
|
||||
write(
|
||||
'gallery.yaml',
|
||||
`albums:\n - "${A}":\n title: "Alpha, renamed"\n description: "Now described"\n - "${B}"\n`,
|
||||
);
|
||||
|
||||
const after = await immich.getAlbums();
|
||||
const alpha = after.find((a) => a.id === A)!;
|
||||
expect(alpha.albumName).toBe('Alpha, renamed');
|
||||
expect(alpha.description).toBe('Now described');
|
||||
expect(alpha.slug).toBe('alpha-renamed');
|
||||
expect(listRequests()).toBe(1);
|
||||
});
|
||||
|
||||
it('drops an unpublished album and adds a newly published one at once', async () => {
|
||||
write('gallery.yaml', `albums:\n - "${A}"\n - "${B}"\n`);
|
||||
expect((await immich.getAlbums()).map((a) => a.id)).toEqual([A, B]);
|
||||
|
||||
write('gallery.yaml', `albums:\n - "${B}"\n - "${C}"\n`);
|
||||
expect((await immich.getAlbums()).map((a) => a.id).sort()).toEqual([B, C].sort());
|
||||
expect(listRequests()).toBe(1);
|
||||
});
|
||||
|
||||
it('refetches the list once for an album created in Immich after it was cached', async () => {
|
||||
write('gallery.yaml', `albums:\n - "${A}"\n`);
|
||||
await immich.getAlbums();
|
||||
|
||||
immichAlbums = [
|
||||
...immichAlbums,
|
||||
{ id: NEW, albumName: 'Fresh', description: '', assetCount: 1 },
|
||||
];
|
||||
write('gallery.yaml', `albums:\n - "${A}"\n - "${NEW}"\n`);
|
||||
expect((await immich.getAlbums()).map((a) => a.id)).toContain(NEW);
|
||||
expect(listRequests()).toBe(2);
|
||||
|
||||
// Cached again, and an allowlisted album Immich does not know (deleted)
|
||||
// costs no request per call.
|
||||
write(
|
||||
'gallery.yaml',
|
||||
`albums:\n - "${A}"\n - "${NEW}"\n - "${'5'.repeat(8)}-5555-4555-8555-555555555555"\n`,
|
||||
);
|
||||
await immich.getAlbums();
|
||||
await immich.getAlbums();
|
||||
await immich.getAlbums();
|
||||
expect(listRequests()).toBe(3);
|
||||
});
|
||||
|
||||
it('applies overrides to a single album from the cache as well', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (String(url).includes('/search/metadata')) {
|
||||
return json({ assets: { items: [], nextPage: null } });
|
||||
}
|
||||
if (String(url).endsWith(`/albums/${A}`)) {
|
||||
return json({ id: A, albumName: 'Alpha', description: '', assetCount: 0, order: 'desc' });
|
||||
}
|
||||
return json(immichAlbums);
|
||||
});
|
||||
write('gallery.yaml', `albums:\n - "${A}"\n`);
|
||||
expect((await immich.getAlbum(A))?.albumName).toBe('Alpha');
|
||||
const albumRequests = () =>
|
||||
fetchMock.mock.calls.filter(([url]) => String(url).endsWith(`/albums/${A}`)).length;
|
||||
expect(albumRequests()).toBe(1);
|
||||
|
||||
write('gallery.yaml', `albums:\n - "${A}": Alpha Prime\n`);
|
||||
const album = await immich.getAlbum(A);
|
||||
expect(album?.albumName).toBe('Alpha Prime');
|
||||
expect(album?.slug).toBe('alpha-prime');
|
||||
expect(albumRequests()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subpage albums follow the configured order (A-6)', () => {
|
||||
it('lists albums in gallery.yaml order, not Immich order', async () => {
|
||||
write(
|
||||
'gallery.yaml',
|
||||
`subpages:\n - name: Trips\n albums:\n - "${B}"\n - "${A}"\n - "${C}"\n`,
|
||||
);
|
||||
const result = await immich.getSubpageAlbums('trips');
|
||||
expect(result?.albums.map((a) => a.id)).toEqual([B, A, C]);
|
||||
|
||||
// Rearranged in the page builder: the next call follows.
|
||||
write(
|
||||
'gallery.yaml',
|
||||
`subpages:\n - name: Trips\n albums:\n - "${C}"\n - "${B}"\n - "${A}"\n`,
|
||||
);
|
||||
const reordered = await immich.getSubpageAlbums('trips');
|
||||
expect(reordered?.albums.map((a) => a.id)).toEqual([C, B, A]);
|
||||
expect(listRequests()).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps section order across sections', async () => {
|
||||
write(
|
||||
'gallery.yaml',
|
||||
[
|
||||
'subpages:',
|
||||
' - name: Japan',
|
||||
' sections:',
|
||||
' - title: South',
|
||||
' albums:',
|
||||
` - "${B}"`,
|
||||
' - title: North',
|
||||
' albums:',
|
||||
` - "${C}"`,
|
||||
` - "${A}"`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const result = await immich.getSubpageAlbums('japan');
|
||||
expect(result?.albums.map((a) => a.id)).toEqual([B, C, A]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cache is one per process, not one per module instance', () => {
|
||||
it('is cleared for every instance by a clear in any of them', async () => {
|
||||
vi.resetModules();
|
||||
const other = await import('@/lib/cache');
|
||||
expect(other.cache).not.toBe(cache);
|
||||
|
||||
cache.set('probe', 1, 60_000);
|
||||
expect(other.cache.get('probe')).toBe(1);
|
||||
expect(other.cache.size).toBe(cache.size);
|
||||
|
||||
other.cache.clear();
|
||||
expect(cache.get('probe')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -80,9 +80,11 @@ describe('ImmichClient — install state', () => {
|
||||
* 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.
|
||||
* bundle's module instance and never reached this cache. The cache now holds
|
||||
* Immich's list as sent and the allowlist is applied per call, so the album
|
||||
* shows up from the cached list without a second request.
|
||||
*/
|
||||
it('does not cache the empty album list while setup is unfinished', async () => {
|
||||
it('does not keep the empty album list of the setup config after setup', async () => {
|
||||
const album = {
|
||||
id: 'album-1',
|
||||
albumName: 'Japan',
|
||||
@@ -106,10 +108,9 @@ describe('ImmichClient — install state', () => {
|
||||
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);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('makes no request at all when credentials are absent', async () => {
|
||||
|
||||
@@ -16,7 +16,8 @@ vi.mock('../config', async () => {
|
||||
const actual = await vi.importActual<typeof config>('../config');
|
||||
return {
|
||||
...actual,
|
||||
getConfig: () => ({
|
||||
// A spy, so the tests below can count how often the client reads it.
|
||||
getConfig: vi.fn(() => ({
|
||||
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
|
||||
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||
albums: ['album-1', 'album-2'],
|
||||
@@ -34,7 +35,7 @@ vi.mock('../config', async () => {
|
||||
cacheTtl: 60_000,
|
||||
staleMaxAge: 86_400_000,
|
||||
immichTimeoutMs: 15000,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -567,6 +568,59 @@ describe('ImmichClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* getConfig() re-checks the content files and clones the whole config on
|
||||
* every call, and the client used to call it once per property it read —
|
||||
* four times per streamed image, twice per album on a list refresh.
|
||||
*/
|
||||
describe('reads the config once per operation', () => {
|
||||
const configReads = () => vi.mocked(config.getConfig).mock.calls.length;
|
||||
const json = (body: unknown) => ({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
it('streamAsset(): once per image', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: new ReadableStream(),
|
||||
headers: { get: () => 'image/jpeg' },
|
||||
});
|
||||
|
||||
await immich.streamAsset('asset-1', 'preview');
|
||||
|
||||
expect(configReads()).toBe(1);
|
||||
});
|
||||
|
||||
it('getAlbums(): twice on a refresh, however many albums there are', async () => {
|
||||
const album = (id: string) => ({ id, albumName: id, assetCount: 1, assets: [] });
|
||||
mockFetch.mockResolvedValueOnce(json([album('album-1'), album('album-2')]));
|
||||
|
||||
expect(await immich.getAlbums()).toHaveLength(2);
|
||||
|
||||
// One for the request, one for filtering, naming and caching the list.
|
||||
expect(configReads()).toBe(2);
|
||||
});
|
||||
|
||||
it('getAlbum(): twice on a cache hit', async () => {
|
||||
mockFetch.mockImplementation(async (url: string) =>
|
||||
json(
|
||||
url.includes('/search/metadata')
|
||||
? { assets: { items: [], nextPage: null } }
|
||||
: { id: 'album-1', albumName: 'A', assetCount: 0, assets: [], order: 'desc' },
|
||||
),
|
||||
);
|
||||
await immich.getAlbum('album-1');
|
||||
vi.mocked(config.getConfig).mockClear();
|
||||
|
||||
await immich.getAlbum('album-1');
|
||||
|
||||
// The allowlist check, then the configured sort.
|
||||
expect(configReads()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlbum()', () => {
|
||||
it('returns null for unallowed album IDs', async () => {
|
||||
const album = await immich.getAlbum('album-3');
|
||||
@@ -821,6 +875,85 @@ describe('ImmichClient', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Album blocks in journal entries, content pages and essay files are
|
||||
* rendered from getAlbumAssetsRaw(), and so is the published-asset set. It
|
||||
* used to go to Immich on every call — two requests per album per page view,
|
||||
* and again on every set rebuild — with no cache and no coalescing.
|
||||
*/
|
||||
describe('getAlbumAssetsRaw() request count', () => {
|
||||
function mockRawAlbumApi(albumId: string) {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
const body = url.includes('/search/metadata')
|
||||
? {
|
||||
assets: {
|
||||
items: [
|
||||
{ id: 'old', isTrashed: false, fileCreatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'gone', isTrashed: true, fileCreatedAt: '2024-01-02T00:00:00Z' },
|
||||
{ id: 'new', isTrashed: false, fileCreatedAt: '2024-01-03T00:00:00Z' },
|
||||
],
|
||||
nextPage: null,
|
||||
},
|
||||
}
|
||||
: { id: albumId, albumName: 'Raw', assetCount: 3, assets: [], order: 'desc' };
|
||||
return { ok: true, headers: { get: () => 'application/json' }, json: async () => body };
|
||||
});
|
||||
}
|
||||
|
||||
it('asks Immich once for many concurrent callers and serves repeats from the cache', async () => {
|
||||
mockRawAlbumApi('unlisted');
|
||||
|
||||
const lists = await Promise.all(
|
||||
Array.from({ length: 10 }, () => immich.getAlbumAssetsRaw('unlisted')),
|
||||
);
|
||||
// One album request plus one metadata page — not 2 × 10.
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
await immich.getAlbumAssetsRaw('unlisted');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Unchanged result: trashed dropped, capture-time order of a 'desc' album.
|
||||
for (const list of lists) expect(list.map((a) => a.id)).toEqual(['new', 'old']);
|
||||
});
|
||||
|
||||
it('shares the load with getAlbum() for an allowlisted album', async () => {
|
||||
mockRawAlbumApi('album-1');
|
||||
|
||||
await immich.getAlbum('album-1');
|
||||
const raw = await immich.getAlbumAssetsRaw('album-1');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(raw.map((a) => a.id)).toEqual(['new', 'old']);
|
||||
});
|
||||
|
||||
it('hands each caller its own array, so no one reorders the cached entry', async () => {
|
||||
mockRawAlbumApi('unlisted');
|
||||
|
||||
const first = await immich.getAlbumAssetsRaw('unlisted');
|
||||
first.reverse();
|
||||
|
||||
expect((await immich.getAlbumAssetsRaw('unlisted')).map((a) => a.id)).toEqual(['new', 'old']);
|
||||
});
|
||||
|
||||
it('goes back to Immich when the admin editors ask for a fresh copy', async () => {
|
||||
mockRawAlbumApi('unlisted');
|
||||
|
||||
await immich.getAlbumAssetsRaw('unlisted');
|
||||
await immich.getAlbumAssetsRaw('unlisted', true);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('still does not open getAlbum() to an album outside the allowlist', async () => {
|
||||
mockRawAlbumApi('unlisted');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await immich.getAlbumAssetsRaw('unlisted');
|
||||
|
||||
expect(await immich.getAlbum('unlisted')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale fallback when Immich is unavailable', () => {
|
||||
|
||||
66
lib/__tests__/journal-order.test.ts
Normal file
66
lib/__tests__/journal-order.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
/**
|
||||
* listJournalEntries sorted by date only, so two entries from the same day kept
|
||||
* whatever order readdir returned — the /journal index and the entry page's
|
||||
* prev/next links then depended on the filesystem. The slug breaks the tie.
|
||||
*/
|
||||
let root: string;
|
||||
let journalDir: string;
|
||||
let service: typeof import('../admin/journal-service');
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'folio-journal-order-'));
|
||||
journalDir = path.join(root, 'content', 'journal');
|
||||
await fs.mkdir(journalDir, { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.resetModules();
|
||||
service = await import('../admin/journal-service');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const entry = (title: string, date?: string) =>
|
||||
`---\ntitle: "${title}"\n${date ? `date: "${date}"\n` : ''}---\n\nBody\n`;
|
||||
|
||||
/** Make readdir hand the files back in a fixed order. */
|
||||
function readdirOrder(order: 'asc' | 'desc') {
|
||||
const real = fs.readdir.bind(fs) as (dir: string) => Promise<string[]>;
|
||||
vi.spyOn(fs, 'readdir').mockImplementation((async (dir: string) => {
|
||||
const names = [...(await real(dir))].sort();
|
||||
return order === 'asc' ? names : names.reverse();
|
||||
}) as unknown as typeof fs.readdir);
|
||||
}
|
||||
|
||||
describe('listJournalEntries order', () => {
|
||||
it.each(['asc', 'desc'] as const)(
|
||||
'orders same-day entries by slug whatever readdir returns (%s)',
|
||||
async (order) => {
|
||||
await fs.writeFile(path.join(journalDir, 'alpha.md'), entry('Zebra', '2026-03-15'));
|
||||
await fs.writeFile(path.join(journalDir, 'beta.md'), entry('Aardvark', '2026-03-15'));
|
||||
await fs.writeFile(path.join(journalDir, 'newer.md'), entry('Newer', '2026-04-01'));
|
||||
readdirOrder(order);
|
||||
|
||||
const slugs = (await service.listJournalEntries()).map((e) => e.slug);
|
||||
expect(slugs).toEqual(['newer', 'alpha', 'beta']);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['asc', 'desc'] as const)(
|
||||
'orders undated entries with the same title by slug (%s)',
|
||||
async (order) => {
|
||||
await fs.writeFile(path.join(journalDir, 'one.md'), entry('Untitled'));
|
||||
await fs.writeFile(path.join(journalDir, 'two.md'), entry('Untitled'));
|
||||
readdirOrder(order);
|
||||
|
||||
const slugs = (await service.listJournalEntries()).map((e) => e.slug);
|
||||
expect(slugs).toEqual(['one', 'two']);
|
||||
},
|
||||
);
|
||||
});
|
||||
268
lib/__tests__/journal-photo-caption.test.ts
Normal file
268
lib/__tests__/journal-photo-caption.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateReadingTime,
|
||||
parseJournalMarkdown,
|
||||
renderInlineMarkdown,
|
||||
serializeJournalMarkdown,
|
||||
} from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
|
||||
const ID2 = '7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d';
|
||||
const ID3 = 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e';
|
||||
|
||||
const serialize = (blocks: JournalBlock[]) =>
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] });
|
||||
const roundTrip = (source: string) => serializeJournalMarkdown(parseJournalMarkdown(source));
|
||||
|
||||
/** No block that renders as text may carry an asset id. */
|
||||
function expectNoIdInText(blocks: JournalBlock[], ids: string[]) {
|
||||
for (const block of blocks) {
|
||||
const text =
|
||||
block.type === 'paragraph'
|
||||
? block.html
|
||||
: block.type === 'heading'
|
||||
? block.text
|
||||
: block.type === 'quote'
|
||||
? block.text
|
||||
: 'caption' in block
|
||||
? (block.caption ?? '')
|
||||
: '';
|
||||
for (const id of ids) expect(text).not.toContain(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Captions that broke, or could break, the `` framing. */
|
||||
const TRICKY_CAPTIONS = [
|
||||
'Gipfel (560 m)',
|
||||
'(560 m)',
|
||||
'Ende :)',
|
||||
'Anfang :(',
|
||||
'((doppelt))',
|
||||
')',
|
||||
'(',
|
||||
'a) b (c',
|
||||
'Siehe [Karte](https://example.com/map_(1)) oben',
|
||||
'[Link](https://immich.app)',
|
||||
'**fett (mit Klammer)** und *kursiv*',
|
||||
'eckige [Klammern] ohne Link',
|
||||
'] mitten [',
|
||||
'Pfad C:\\Fotos\\',
|
||||
'Backslash vor Klammer \\) und \\(',
|
||||
'Emoji 🏔️ (Gipfel) 📷',
|
||||
'Umlaute: Größe, Übersicht, Äpfel (Öl)',
|
||||
'Anna\'s "Zitat" & <Tag>',
|
||||
'',
|
||||
];
|
||||
|
||||
describe('photo captions with parentheses (A-1)', () => {
|
||||
it('keeps a wide photo with `(560 m)` in its caption a photo block', () => {
|
||||
const source = `)`;
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'photo', assetId: ID, layout: 'wide', caption: 'Gipfel (560 m)' },
|
||||
]);
|
||||
expect(roundTrip(source)).toBe(source);
|
||||
});
|
||||
|
||||
it('keeps pair and grid captions with parentheses intact', () => {
|
||||
const pair = `, rechts (Süd))`;
|
||||
expect(parseJournalMarkdown(pair).blocks[0]).toEqual({
|
||||
type: 'photo-pair',
|
||||
assetIds: [ID, ID2],
|
||||
caption: 'Links (Nord), rechts (Süd)',
|
||||
});
|
||||
expect(roundTrip(pair)).toBe(pair);
|
||||
|
||||
const grid = `)`;
|
||||
expect(parseJournalMarkdown(grid).blocks[0]).toEqual({
|
||||
type: 'photo-grid',
|
||||
assetIds: [ID, ID2, ID3],
|
||||
caption: 'Drei (3)',
|
||||
});
|
||||
expect(roundTrip(grid)).toBe(grid);
|
||||
});
|
||||
|
||||
it('renders a link inside a caption', () => {
|
||||
const parsed = parseJournalMarkdown(`)`);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'photo',
|
||||
caption:
|
||||
'Mehr auf <a href="https://immich.app" target="_blank" rel="noopener noreferrer">Immich</a>',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps balanced parentheses inside a link URL', () => {
|
||||
const source = `, siehe [Karte](https://example.com/map_(1)))`;
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'photo',
|
||||
caption:
|
||||
'Gipfel (560 m), siehe <a href="https://example.com/map_(1)" target="_blank" rel="noopener noreferrer">Karte</a>',
|
||||
});
|
||||
expect(roundTrip(source)).toBe(source);
|
||||
// Outside a link, a parenthesis after the URL is still text.
|
||||
expect(renderInlineMarkdown('[a](https://x.y) (b)')).toBe(
|
||||
'<a href="https://x.y" target="_blank" rel="noopener noreferrer">a</a> (b)',
|
||||
);
|
||||
});
|
||||
|
||||
for (const caption of TRICKY_CAPTIONS) {
|
||||
it(`round-trips the caption ${JSON.stringify(caption)} for every photo block type`, () => {
|
||||
const html = renderInlineMarkdown(caption);
|
||||
const blocks: JournalBlock[] = [
|
||||
{ type: 'photo', assetId: ID, layout: 'fullbleed', caption: html },
|
||||
{ type: 'photo', assetId: ID, layout: 'contained', caption: html },
|
||||
{ type: 'photo-pair', assetIds: [ID, ID2], caption: html },
|
||||
{ type: 'photo-grid', assetIds: [ID, ID2, ID3], caption: html },
|
||||
];
|
||||
for (const block of blocks) {
|
||||
const once = serialize([block]);
|
||||
const parsed = parseJournalMarkdown(once);
|
||||
// The framing is exact: the caption comes back as the markdown that
|
||||
// was written, rendered once, and the block keeps its type and ids.
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
const [back] = parsed.blocks;
|
||||
expect(back.type).toBe(block.type);
|
||||
expect(back).toMatchObject(
|
||||
block.type === 'photo'
|
||||
? { assetId: ID, layout: block.layout }
|
||||
: { assetIds: 'assetIds' in block ? block.assetIds : [] },
|
||||
);
|
||||
const writtenCaption = once.slice(once.indexOf('](') + 2, -1);
|
||||
expect('caption' in back ? back.caption : undefined).toBe(
|
||||
renderInlineMarkdown(writtenCaption.trim()),
|
||||
);
|
||||
// And a second save writes the same file.
|
||||
expect(serialize(parsed.blocks)).toBe(once);
|
||||
expectNoIdInText(
|
||||
parsed.blocks.filter((b) => b.type === 'paragraph'),
|
||||
[ID, ID2, ID3],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('parses any single-line caption exactly (seeded fuzz)', () => {
|
||||
const alphabet = ['(', ')', '[', ']', '\\', '*', '_', ' ', 'a', 'Ü', '🏔️', '!', ':', ','];
|
||||
let seed = 0x5eed;
|
||||
const next = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed;
|
||||
};
|
||||
for (let n = 0; n < 500; n++) {
|
||||
const length = next() % 24;
|
||||
let caption = '';
|
||||
for (let i = 0; i < length; i++) caption += alphabet[next() % alphabet.length];
|
||||
const source = ``;
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID, layout: 'wide' });
|
||||
const expected = caption.trim() ? renderInlineMarkdown(caption.trim()) : undefined;
|
||||
expect((parsed.blocks[0] as { caption?: string }).caption).toBe(expected);
|
||||
// Idempotence of a save. Stray `*`/`_` runs are left out: the inline
|
||||
// emphasis renderer normalises those on its own (`__*` → `***`), which is
|
||||
// a property of renderInlineMarkdown, not of the photo-line framing.
|
||||
if (!/[*_]/.test(caption)) {
|
||||
const once = serializeJournalMarkdown(parsed);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo lines the old pattern accepted parse exactly as before', () => {
|
||||
// The pattern before A-1, kept here as the reference for backward compatibility.
|
||||
const legacy = (line: string) => {
|
||||
const m = line.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
|
||||
return m ? { target: m[1].trim(), caption: m[2].trim() } : null;
|
||||
};
|
||||
|
||||
const lines = [
|
||||
`![${ID}]()`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
`![:wide]()`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
it(JSON.stringify(line), () => {
|
||||
const old = legacy(line);
|
||||
expect(old).not.toBeNull();
|
||||
const blocks = parseJournalMarkdown(line).blocks;
|
||||
expect(blocks).toHaveLength(1);
|
||||
const expectedCaption = old!.caption ? renderInlineMarkdown(old!.caption) : undefined;
|
||||
expect((blocks[0] as { caption?: string }).caption).toBe(expectedCaption);
|
||||
const ids =
|
||||
blocks[0].type === 'photo'
|
||||
? [blocks[0].assetId]
|
||||
: 'assetIds' in blocks[0]
|
||||
? blocks[0].assetIds
|
||||
: [];
|
||||
expect(ids.join(', ')).toBe(old!.target.replace(/:(wide|fullbleed)$/, ''));
|
||||
// Unchanged content saves to the same line.
|
||||
expect(roundTrip(line)).toBe(roundTrip(roundTrip(line)));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('a malformed photo line never prints asset ids (A-1)', () => {
|
||||
it('splits two photo lines written without a blank line between them', () => {
|
||||
const parsed = parseJournalMarkdown(`\n)`);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'photo', assetId: ID, layout: 'contained', caption: 'Erstes' },
|
||||
{ type: 'photo', assetId: ID2, layout: 'wide', caption: 'Zweites (2)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits a heading or sentence directly above a photo', () => {
|
||||
const parsed = parseJournalMarkdown(`## Tag 1\n`);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'heading', level: 2, text: 'Tag 1' },
|
||||
{ type: 'photo', assetId: ID, layout: 'contained', caption: 'Gipfel' },
|
||||
]);
|
||||
|
||||
const para = parseJournalMarkdown(`Wir stiegen auf.\n `);
|
||||
expect(para.blocks.map((b) => b.type)).toEqual(['paragraph', 'photo-pair']);
|
||||
expectNoIdInText(para.blocks, [ID, ID2]);
|
||||
});
|
||||
|
||||
it('keeps text after the closing parenthesis in the caption, not a paragraph', () => {
|
||||
const parsed = parseJournalMarkdown(` und weiter`);
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID });
|
||||
expectNoIdInText(parsed.blocks, [ID]);
|
||||
// Stable from the first save on.
|
||||
const once = roundTrip(` und weiter`);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('treats a reference without a caption group as a photo', () => {
|
||||
for (const source of [`![${ID}]`, `![${ID}:wide] Text`, `![${ID}`]) {
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID });
|
||||
expectNoIdInText(parsed.blocks, [ID]);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves directive chunks alone', () => {
|
||||
const parsed = parseJournalMarkdown(`::album abc\ncaption: `);
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'album', albumId: 'abc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading time ignores photo lines', () => {
|
||||
it('drops the whole line, parentheses in the caption included', () => {
|
||||
const { words } = calculateReadingTime(`Eins zwei.\n\n oben)`);
|
||||
expect(words).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
serializeJournalMarkdown,
|
||||
@@ -7,22 +10,8 @@ import {
|
||||
isValidSlug,
|
||||
sanitizeSlug,
|
||||
} from '../journal';
|
||||
import {
|
||||
writeJournalEntry,
|
||||
readJournalEntry,
|
||||
deleteJournalEntry,
|
||||
listJournalEntries,
|
||||
} from '../admin/journal-service';
|
||||
|
||||
describe('Journal Service & Parser', () => {
|
||||
const testSlug = 'test-journey-nordkap';
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await deleteJournalEntry(testSlug);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
describe('slug validation and sanitization', () => {
|
||||
it('validates safe alphanumeric and hyphenated slugs', () => {
|
||||
expect(isValidSlug('my-story-2026')).toBe(true);
|
||||
@@ -149,7 +138,28 @@ A paragraph of text.
|
||||
});
|
||||
|
||||
describe('filesystem CRUD operations', () => {
|
||||
// The service resolves content/journal from process.cwd() at import time.
|
||||
// Without a temp cwd this wrote a real entry into the checkout's content/
|
||||
// — the live site's, in a deployment — and left a .deleted.bak behind.
|
||||
const testSlug = 'test-journey-nordkap';
|
||||
let root: string;
|
||||
let service: typeof import('../admin/journal-service');
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-crud-'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.resetModules();
|
||||
service = await import('../admin/journal-service');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes, reads, lists and deletes journal files safely', async () => {
|
||||
const { writeJournalEntry, readJournalEntry, deleteJournalEntry, listJournalEntries } =
|
||||
service;
|
||||
const testContent = `---
|
||||
title: "Vitest Journey"
|
||||
date: "2026-08-14"
|
||||
|
||||
51
lib/__tests__/journalDate.test.ts
Normal file
51
lib/__tests__/journalDate.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { formatJournalDate } from '../journalDate';
|
||||
|
||||
/**
|
||||
* The /journal index formatted `new Date(frontmatter.date)` in the server's
|
||||
* timezone: `2026-03-15` is UTC midnight, so every server west of Greenwich
|
||||
* printed Mar 14. Hand-typed dates became "Invalid Date", and the entry page
|
||||
* printed the raw string next to the index's localized one.
|
||||
*
|
||||
* Run under a negative UTC offset so a regression to local-time formatting
|
||||
* shows up on any machine.
|
||||
*/
|
||||
let savedTz: string | undefined;
|
||||
beforeAll(() => {
|
||||
savedTz = process.env.TZ;
|
||||
process.env.TZ = 'America/Los_Angeles';
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = savedTz;
|
||||
});
|
||||
|
||||
describe('formatJournalDate', () => {
|
||||
it('keeps the calendar day of a date-only value west of UTC', () => {
|
||||
// Sanity check that the offset is in effect: this is the old code path.
|
||||
expect(new Date('2026-03-15').getDate()).toBe(14);
|
||||
expect(formatJournalDate('2026-03-15', 'en-US')).toBe('Mar 15, 2026');
|
||||
expect(formatJournalDate('2026-01-01', 'de-DE')).toBe('1. Jan. 2026');
|
||||
});
|
||||
|
||||
it('takes the written day of a value with a time', () => {
|
||||
expect(formatJournalDate('2026-03-15T23:30:00+01:00', 'en-US')).toBe('Mar 15, 2026');
|
||||
expect(formatJournalDate('2026-03-15 08:00', 'en-US')).toBe('Mar 15, 2026');
|
||||
});
|
||||
|
||||
it('shows a year-month value without inventing a day', () => {
|
||||
expect(formatJournalDate('2026-03', 'en-US')).toBe('March 2026');
|
||||
});
|
||||
|
||||
it.each(['15.03.2026', 'March 2026', 'Spring 2026', '2026-02-30', '2026-13', '2026-03-150'])(
|
||||
'shows %s as written instead of Invalid Date or a guessed day',
|
||||
(raw) => {
|
||||
expect(formatJournalDate(raw, 'en-US')).toBe(raw);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns null for a missing or blank date', () => {
|
||||
expect(formatJournalDate(undefined, 'en-US')).toBeNull();
|
||||
expect(formatJournalDate(' ', 'en-US')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,15 @@ vi.mock('@/lib/immich', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const config = vi.hoisted(() => ({
|
||||
value: {
|
||||
standaloneAlbums: ['album-1'] as string[],
|
||||
subpages: [] as Array<{ slug: string; albumIds: string[]; enabled: boolean; hidden?: boolean }>,
|
||||
cacheTtl: 60_000,
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({ subpages: [], cacheTtl: 60_000 }),
|
||||
getConfig: () => config.value,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
@@ -22,6 +29,7 @@ vi.mock('@/lib/cache', () => ({
|
||||
}));
|
||||
|
||||
import { getMapData } from '@/lib/mapService';
|
||||
import { listedAlbumIds } from '@/lib/config/schema';
|
||||
|
||||
const ALBUM = { id: 'album-1', albumName: 'Trip', slug: 'trip' };
|
||||
|
||||
@@ -30,6 +38,8 @@ function asset(id: string, exif: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
config.value.standaloneAlbums = ['album-1'];
|
||||
config.value.subpages = [];
|
||||
getAlbums.mockReset();
|
||||
getAlbum.mockReset();
|
||||
getAlbums.mockResolvedValue([ALBUM]);
|
||||
@@ -83,3 +93,101 @@ describe('getMapData coordinate filtering (#635)', () => {
|
||||
expect(locations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `enabled: false` takes a subpage offline. Its albums stay on the allowlist
|
||||
* (the configuration is kept), so the map has to leave them out itself: a
|
||||
* marker names the album, links it, counts it and shows one of its photos.
|
||||
*/
|
||||
describe('getMapData and offline subpages', () => {
|
||||
const berlin = { latitude: 52.5, longitude: 13.4, city: 'Berlin', country: 'Germany' };
|
||||
|
||||
beforeEach(() => {
|
||||
getAlbum.mockResolvedValue({ id: ALBUM.id, assets: [asset('a', berlin)] });
|
||||
});
|
||||
|
||||
it('leaves out an album whose only subpage is offline', async () => {
|
||||
config.value.standaloneAlbums = [];
|
||||
config.value.subpages = [{ slug: 'old-series', albumIds: [ALBUM.id], enabled: false }];
|
||||
|
||||
expect(await getMapData()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps it when an enabled subpage lists it too, and links it there', async () => {
|
||||
config.value.standaloneAlbums = [];
|
||||
config.value.subpages = [
|
||||
{ slug: 'old-series', albumIds: [ALBUM.id], enabled: false },
|
||||
{ slug: 'travel', albumIds: [ALBUM.id], enabled: true },
|
||||
];
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].albums[0].subpageSlug).toBe('travel');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `hidden: true` keeps a subpage reachable by direct link only. The map is a
|
||||
* listing: a marker names the album, links it under the subpage's address and
|
||||
* shows one of its photos, so an album only a hidden subpage carries must not
|
||||
* appear on it. Its photos stay reachable through the link (publishedAssets
|
||||
* still asks onlineAlbumIds, which is not narrowed).
|
||||
*/
|
||||
describe('getMapData and hidden subpages', () => {
|
||||
const berlin = { latitude: 52.5, longitude: 13.4, city: 'Berlin', country: 'Germany' };
|
||||
|
||||
beforeEach(() => {
|
||||
getAlbum.mockResolvedValue({ id: ALBUM.id, assets: [asset('a', berlin)] });
|
||||
});
|
||||
|
||||
it('leaves out an album whose only subpage is hidden', async () => {
|
||||
config.value.standaloneAlbums = [];
|
||||
config.value.subpages = [
|
||||
{ slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true },
|
||||
];
|
||||
|
||||
expect(await getMapData()).toEqual([]);
|
||||
expect(getAlbum).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps it when a listed subpage carries it too, and links it there', async () => {
|
||||
config.value.standaloneAlbums = [];
|
||||
config.value.subpages = [
|
||||
{ slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true },
|
||||
{ slug: 'travel', albumIds: [ALBUM.id], enabled: true },
|
||||
];
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].albums[0].subpageSlug).toBe('travel');
|
||||
});
|
||||
|
||||
it('keeps a standalone album that a hidden subpage also carries, linked standalone', async () => {
|
||||
config.value.standaloneAlbums = [ALBUM.id];
|
||||
config.value.subpages = [
|
||||
{ slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true },
|
||||
];
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].albums[0].subpageSlug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listedAlbumIds', () => {
|
||||
it('drops albums of offline and hidden subpages, keeps standalone and listed ones', () => {
|
||||
const ids = listedAlbumIds({
|
||||
standaloneAlbums: ['s'],
|
||||
subpages: [
|
||||
{ albumIds: ['v', 'shared'], enabled: true },
|
||||
{ albumIds: ['off'], enabled: false },
|
||||
{ albumIds: ['h', 'shared'], enabled: true, hidden: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect([...ids].sort()).toEqual(['s', 'shared', 'v']);
|
||||
});
|
||||
});
|
||||
|
||||
97
lib/__tests__/pages-service.test.ts
Normal file
97
lib/__tests__/pages-service.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* content/pages/ storage (#722): the journal's rules — valid slugs only, a
|
||||
* backup before every overwrite, ten saves kept, a `.deleted.bak` on delete.
|
||||
*/
|
||||
let root: string;
|
||||
let pagesDir: string;
|
||||
let backupDir: string;
|
||||
let service: typeof import('../admin/pages-service');
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-pages-'));
|
||||
pagesDir = path.join(root, 'content', 'pages');
|
||||
backupDir = path.join(pagesDir, '.backups');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
service = await import('../admin/pages-service');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const page = (title: string, extra = '') =>
|
||||
`---\ntitle: "${title}"\n${extra}---\n\nAbout ${title}\n`;
|
||||
|
||||
describe('pages-service', () => {
|
||||
it('writes, reads and lists a page', async () => {
|
||||
await service.writePage('pricing', page('Pricing', 'draft: true\n'));
|
||||
|
||||
const read = await service.readPage('pricing');
|
||||
expect(read?.parsed.frontmatter).toMatchObject({ title: 'Pricing', draft: true });
|
||||
expect(service.readPageSync('pricing')?.rawMarkdown).toBe(read?.rawMarkdown);
|
||||
|
||||
const list = await service.listPages();
|
||||
expect(list).toEqual([
|
||||
{ slug: 'pricing', frontmatter: expect.objectContaining({ title: 'Pricing' }) },
|
||||
]);
|
||||
expect(service.listPageSlugsSync()).toEqual(['pricing']);
|
||||
});
|
||||
|
||||
it('keeps the SEO description in the frontmatter', async () => {
|
||||
await service.writePage('faq', page('FAQ', 'description: "Questions, answered"\n'));
|
||||
expect((await service.readPage('faq'))?.parsed.frontmatter.description).toBe(
|
||||
'Questions, answered',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a slug that is not one, and never touches a path outside', async () => {
|
||||
await expect(service.writePage('../escape', 'x')).rejects.toThrow(/Invalid page slug/);
|
||||
await expect(service.writePage('a/b', 'x')).rejects.toThrow(/Invalid page slug/);
|
||||
expect(await service.readPage('../gallery')).toBeNull();
|
||||
expect(service.resolvePageFilePath('..')).toBeNull();
|
||||
});
|
||||
|
||||
it('answers null for a page that does not exist', async () => {
|
||||
expect(await service.readPage('nothing')).toBeNull();
|
||||
expect(await service.listPages()).toEqual([]);
|
||||
});
|
||||
|
||||
it('backs up the previous version before an overwrite, keeping ten', async () => {
|
||||
await mkdir(pagesDir, { recursive: true });
|
||||
await writeFile(path.join(pagesDir, 'pricing.md'), page('v0'));
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await service.writePage('pricing', page(`v${i}`));
|
||||
// Distinct timestamps in the backup names.
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
const backups = await readdir(backupDir);
|
||||
expect(backups).toHaveLength(10);
|
||||
expect(await readFile(path.join(pagesDir, 'pricing.md'), 'utf8')).toBe(page('v12'));
|
||||
});
|
||||
|
||||
it('keeps a .deleted.bak copy on delete, and restores from it', async () => {
|
||||
await service.writePage('workshops', page('Workshops'));
|
||||
await expect(service.deletePage('workshops')).resolves.toBe(true);
|
||||
await expect(service.deletePage('workshops')).resolves.toBe(false);
|
||||
expect(await service.readPage('workshops')).toBeNull();
|
||||
|
||||
const [backup] = await service.listPageBackups();
|
||||
expect(backup).toMatchObject({ slug: 'workshops', kind: 'deleted' });
|
||||
|
||||
await expect(service.restorePageBackup(backup.filename)).resolves.toBe('workshops');
|
||||
expect((await service.readPage('workshops'))?.parsed.frontmatter.title).toBe('Workshops');
|
||||
});
|
||||
|
||||
it('refuses to restore from a name it did not write', async () => {
|
||||
await expect(service.restorePageBackup('../gallery.yaml')).rejects.toThrow(/unrecognised/);
|
||||
await expect(service.restorePageBackup('pricing.md')).rejects.toThrow(/unrecognised/);
|
||||
});
|
||||
});
|
||||
155
lib/__tests__/pages.test.ts
Normal file
155
lib/__tests__/pages.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
RESERVED_PAGE_SLUGS,
|
||||
describeCollision,
|
||||
menuPageSlugs,
|
||||
pageSlugCollision,
|
||||
resolveMenu,
|
||||
type NavEntry,
|
||||
type PageSummary,
|
||||
} from '../pages';
|
||||
import { deriveGallery, type GalleryYaml } from '../config';
|
||||
import { rewritePageRefs } from '../admin/pageRefs';
|
||||
|
||||
const A1 = '11111111-1111-4111-8111-111111111111';
|
||||
const A2 = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
const none = { subpages: [], albums: [], journal: [] };
|
||||
|
||||
describe('pageSlugCollision (#722)', () => {
|
||||
it('accepts a free slug', () => {
|
||||
expect(pageSlugCollision('pricing', none)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(RESERVED_PAGE_SLUGS)('refuses the built-in route /%s', (slug) => {
|
||||
expect(pageSlugCollision(slug, none)).toEqual({ kind: 'reserved' });
|
||||
});
|
||||
|
||||
it('refuses a slug taken by a subpage, an album or a journal entry', () => {
|
||||
const taken = { subpages: ['travel'], albums: ['iceland'], journal: ['trip'] };
|
||||
expect(pageSlugCollision('travel', taken)).toEqual({ kind: 'subpage' });
|
||||
expect(pageSlugCollision('iceland', taken)).toEqual({ kind: 'album' });
|
||||
expect(pageSlugCollision('trip', taken)).toEqual({ kind: 'journal' });
|
||||
});
|
||||
|
||||
it('compares without regard to case', () => {
|
||||
expect(pageSlugCollision('Travel', { ...none, subpages: ['travel'] })).toEqual({
|
||||
kind: 'subpage',
|
||||
});
|
||||
expect(pageSlugCollision('ADMIN', none)).toEqual({ kind: 'reserved' });
|
||||
});
|
||||
|
||||
it('refuses what is not a slug at all', () => {
|
||||
expect(pageSlugCollision('a/b', none)).toEqual({ kind: 'invalid' });
|
||||
expect(pageSlugCollision('', none)).toEqual({ kind: 'invalid' });
|
||||
});
|
||||
|
||||
it('describes each collision in a sentence naming the slug', () => {
|
||||
for (const kind of ['invalid', 'reserved', 'subpage', 'album', 'journal'] as const) {
|
||||
expect(describeCollision('x', { kind })).toContain('x');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveGallery: `- page:` references (#722)', () => {
|
||||
const gallery = (subpages: NonNullable<GalleryYaml['subpages']>): GalleryYaml => ({ subpages });
|
||||
|
||||
it('keeps pages and subpages in one menu order', () => {
|
||||
const { nav, subpages } = deriveGallery(
|
||||
gallery([
|
||||
{ page: 'pricing' },
|
||||
{ name: 'Travel', albums: [A1] },
|
||||
{ page: 'faq' },
|
||||
{ name: 'Weddings', albums: [A2] },
|
||||
]),
|
||||
);
|
||||
expect(subpages.map((s) => s.slug)).toEqual(['travel', 'weddings']);
|
||||
expect(nav).toEqual([
|
||||
{ type: 'page', slug: 'pricing' },
|
||||
{ type: 'subpage', slug: 'travel' },
|
||||
{ type: 'page', slug: 'faq' },
|
||||
{ type: 'subpage', slug: 'weddings' },
|
||||
]);
|
||||
expect(menuPageSlugs(nav)).toEqual(['pricing', 'faq']);
|
||||
});
|
||||
|
||||
it('allows a gallery whose menu holds only pages', () => {
|
||||
expect(deriveGallery(gallery([{ page: 'pricing' }])).nav).toEqual([
|
||||
{ type: 'page', slug: 'pricing' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an invalid page slug and a page listed twice', () => {
|
||||
expect(() => deriveGallery(gallery([{ page: '../x' }]))).toThrow(/not a valid page slug/);
|
||||
expect(() => deriveGallery(gallery([{ page: 'a' }, { page: 'a' }]))).toThrow(/more than once/);
|
||||
});
|
||||
|
||||
it('builds the menu from the map form of subpages too', () => {
|
||||
const { nav } = deriveGallery({ subpages: { Travel: [A1] } });
|
||||
expect(nav).toEqual([{ type: 'subpage', slug: 'travel' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMenu (#722)', () => {
|
||||
const nav: NavEntry[] = [
|
||||
{ type: 'subpage', slug: 'travel' },
|
||||
{ type: 'page', slug: 'pricing' },
|
||||
{ type: 'subpage', slug: 'hidden' },
|
||||
{ type: 'page', slug: 'draft' },
|
||||
{ type: 'page', slug: 'missing' },
|
||||
{ type: 'page', slug: 'locked' },
|
||||
];
|
||||
const pages: PageSummary[] = [
|
||||
{ slug: 'pricing', frontmatter: { title: 'Pricing' } },
|
||||
{ slug: 'draft', frontmatter: { title: 'Soon', draft: true } },
|
||||
{ slug: 'locked', frontmatter: { password: 'scrypt:a:b' } },
|
||||
];
|
||||
|
||||
it('interleaves visible subpages and published pages in gallery.yaml order', () => {
|
||||
const menu = resolveMenu(nav, [{ slug: 'travel', name: 'Travel' }], pages);
|
||||
expect(menu.map((m) => [m.href, m.label])).toEqual([
|
||||
['/travel', 'Travel'],
|
||||
['/pricing', 'Pricing'],
|
||||
// A protected page stays listed; its title falls back to the slug.
|
||||
['/locked', 'locked'],
|
||||
]);
|
||||
});
|
||||
|
||||
// `name` is the gallery.yaml key the slug is built from ("south-korea");
|
||||
// `title` is what the page itself shows ("South Korea"). The menu showed
|
||||
// the key.
|
||||
it('labels a subpage with its title, falling back to its name', () => {
|
||||
const menu = resolveMenu(
|
||||
[
|
||||
{ type: 'subpage', slug: 'south-korea' },
|
||||
{ type: 'subpage', slug: 'travel' },
|
||||
],
|
||||
[
|
||||
{ slug: 'south-korea', name: 'south-korea', title: 'South Korea' },
|
||||
{ slug: 'travel', name: 'Travel' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(menu.map((m) => m.label)).toEqual(['South Korea', 'Travel']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewritePageRefs (#722)', () => {
|
||||
const list = [{ name: 'Travel' }, { page: 'pricing' }, { page: 'faq' }];
|
||||
|
||||
it('renames a reference in place', () => {
|
||||
expect(rewritePageRefs(list, 'pricing', 'rates')).toEqual([
|
||||
{ name: 'Travel' },
|
||||
{ page: 'rates' },
|
||||
{ page: 'faq' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a reference', () => {
|
||||
expect(rewritePageRefs(list, 'faq', null)).toEqual([{ name: 'Travel' }, { page: 'pricing' }]);
|
||||
});
|
||||
|
||||
it('returns the same list when nothing matches', () => {
|
||||
expect(rewritePageRefs(list, 'nothing', null)).toBe(list);
|
||||
});
|
||||
});
|
||||
128
lib/__tests__/parsedFileCache.test.ts
Normal file
128
lib/__tests__/parsedFileCache.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, mkdir, rename, rm, unlink, writeFile } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* The journal and page listings run on every public render (the header nav
|
||||
* lists both) and used to parse every markdown file each time. They now keep
|
||||
* the parse per file while the file is unchanged — which must never cost an
|
||||
* admin save its immediate visibility.
|
||||
*/
|
||||
let root: string;
|
||||
let journalDir: string;
|
||||
let pagesDir: string;
|
||||
let parseSpy: ReturnType<typeof vi.fn<(raw: string) => void>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-parsecache-'));
|
||||
journalDir = path.join(root, 'content', 'journal');
|
||||
pagesDir = path.join(root, 'content', 'pages');
|
||||
await mkdir(journalDir, { recursive: true });
|
||||
await mkdir(pagesDir, { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
parseSpy = vi.fn<(raw: string) => void>();
|
||||
vi.doMock('../journal', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../journal')>();
|
||||
return {
|
||||
...actual,
|
||||
parseJournalMarkdown: (raw: string) => {
|
||||
parseSpy(raw);
|
||||
return actual.parseJournalMarkdown(raw);
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.doUnmock('../journal');
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const doc = (title: string) =>
|
||||
`---\ntitle: "${title}"\ndate: "2026-01-01"\n---\n\nBody of ${title}\n`;
|
||||
|
||||
describe('listJournalEntries', () => {
|
||||
it('parses an unchanged file once across listings', async () => {
|
||||
await writeFile(path.join(journalDir, 'a.md'), doc('Alpha'));
|
||||
await writeFile(path.join(journalDir, 'b.md'), doc('Bravo'));
|
||||
const service = await import('../admin/journal-service');
|
||||
|
||||
const first = await service.listJournalEntries();
|
||||
const second = await service.listJournalEntries();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(second.map((e) => e.frontmatter.title).sort()).toEqual(['Alpha', 'Bravo']);
|
||||
expect(parseSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows an admin save on the very next listing, even at the same size', async () => {
|
||||
await writeFile(path.join(journalDir, 'a.md'), doc('Alpha'));
|
||||
const service = await import('../admin/journal-service');
|
||||
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Alpha');
|
||||
|
||||
// Same byte length, so only the inode (atomicWrite renames) tells them apart.
|
||||
await service.writeJournalEntry('a', doc('Omega'));
|
||||
|
||||
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Omega');
|
||||
});
|
||||
|
||||
it('sees an edit made in place and a file renamed over the entry', async () => {
|
||||
const file = path.join(journalDir, 'a.md');
|
||||
await writeFile(file, doc('Alpha'));
|
||||
const service = await import('../admin/journal-service');
|
||||
await service.listJournalEntries();
|
||||
|
||||
await writeFile(file, doc('Alpha, revised'));
|
||||
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Alpha, revised');
|
||||
|
||||
await writeFile(path.join(root, 'tmp.md'), doc('Gamma'));
|
||||
await rename(path.join(root, 'tmp.md'), file);
|
||||
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Gamma');
|
||||
});
|
||||
|
||||
it('drops a deleted entry and hands out copies callers cannot poison', async () => {
|
||||
await writeFile(path.join(journalDir, 'a.md'), doc('Alpha'));
|
||||
await writeFile(path.join(journalDir, 'b.md'), doc('Bravo'));
|
||||
const service = await import('../admin/journal-service');
|
||||
|
||||
const listed = await service.listJournalEntries();
|
||||
listed[0].frontmatter.title = 'mutated';
|
||||
listed[0].excerpt = 'mutated';
|
||||
expect((await service.listJournalEntries()).map((e) => e.frontmatter.title).sort()).toEqual([
|
||||
'Alpha',
|
||||
'Bravo',
|
||||
]);
|
||||
|
||||
await unlink(path.join(journalDir, 'b.md'));
|
||||
expect((await service.listJournalEntries()).map((e) => e.slug)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPages', () => {
|
||||
it('parses an unchanged page once and sees a save at once', async () => {
|
||||
await writeFile(path.join(pagesDir, 'pricing.md'), doc('Pricing'));
|
||||
const service = await import('../admin/pages-service');
|
||||
|
||||
await service.listPages();
|
||||
const again = await service.listPages();
|
||||
expect(again).toEqual([
|
||||
{
|
||||
slug: 'pricing',
|
||||
frontmatter: {
|
||||
title: 'Pricing',
|
||||
description: undefined,
|
||||
password: undefined,
|
||||
draft: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(parseSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await service.writePage('pricing', doc('Tariffs'));
|
||||
expect((await service.listPages())[0].frontmatter.title).toBe('Tariffs');
|
||||
});
|
||||
});
|
||||
52
lib/__tests__/proofExport.test.ts
Normal file
52
lib/__tests__/proofExport.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { baseName, formatExport } from '../proofExport';
|
||||
|
||||
const picks = [
|
||||
{ position: 3, fileName: 'IMG_0412.JPG', takenAt: '2026-05-01T10:00:00' },
|
||||
{ position: 7, fileName: 'IMG_0412.CR3' },
|
||||
{ position: 9, fileName: 'wedding "first", look.jpg' },
|
||||
];
|
||||
|
||||
describe('baseName', () => {
|
||||
it('drops only the last extension', () => {
|
||||
expect(baseName('IMG_0412.JPG')).toBe('IMG_0412');
|
||||
expect(baseName('a.b.tif')).toBe('a.b');
|
||||
expect(baseName('.hidden')).toBe('.hidden');
|
||||
expect(baseName('noext')).toBe('noext');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatExport', () => {
|
||||
it('lists de-duplicated base names for a Lightroom filter', () => {
|
||||
expect(formatExport(picks, 'lightroom')).toBe('IMG_0412, wedding "first", look');
|
||||
});
|
||||
|
||||
it('lists full names one per line', () => {
|
||||
expect(formatExport(picks, 'txt')).toBe(
|
||||
'IMG_0412.JPG\nIMG_0412.CR3\nwedding "first", look.jpg\n',
|
||||
);
|
||||
expect(formatExport([], 'txt')).toBe('');
|
||||
});
|
||||
|
||||
it('writes RFC 4180 CSV', () => {
|
||||
expect(formatExport(picks, 'csv').split('\r\n')).toEqual([
|
||||
'position,file_name,taken_at',
|
||||
'3,IMG_0412.JPG,2026-05-01T10:00:00',
|
||||
'7,IMG_0412.CR3,',
|
||||
'9,"wedding ""first"", look.jpg",',
|
||||
'',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never lets a file name become a spreadsheet formula', () => {
|
||||
const csv = formatExport([{ position: 1, fileName: '=HYPERLINK("x")' }], 'csv');
|
||||
expect(csv).toContain(`"'=HYPERLINK(""x"")"`);
|
||||
});
|
||||
|
||||
it('also neutralises names starting with a tab or carriage return', () => {
|
||||
for (const name of ['\t=1+1', '\r=1+1']) {
|
||||
const row = formatExport([{ position: 1, fileName: name }], 'csv').split('\r\n')[1];
|
||||
expect(row).toMatch(/^1,"?'/);
|
||||
}
|
||||
});
|
||||
});
|
||||
198
lib/__tests__/proofSessions.test.ts
Normal file
198
lib/__tests__/proofSessions.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
claimDownload,
|
||||
createSession,
|
||||
deleteSession,
|
||||
downloadsRemaining,
|
||||
findSessionByToken,
|
||||
getSessionById,
|
||||
isExpired,
|
||||
isTokenShaped,
|
||||
listSessions,
|
||||
newToken,
|
||||
ProofError,
|
||||
saveSelection,
|
||||
sessionInputSchema,
|
||||
sessionPatchSchema,
|
||||
sessionState,
|
||||
submitSelection,
|
||||
updateSession,
|
||||
type ProofSession,
|
||||
} from '../proofSessions';
|
||||
|
||||
const ALBUM = '11111111-1111-1111-1111-111111111111';
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-proof-'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const storePath = () => path.join(dir, 'content', 'proofing.json');
|
||||
|
||||
function input(overrides: Record<string, unknown> = {}) {
|
||||
return sessionInputSchema.parse({ clientName: 'Anna', albumId: ALBUM, ...overrides });
|
||||
}
|
||||
|
||||
describe('tokens', () => {
|
||||
it('are 192-bit URL-safe strings and never repeat', () => {
|
||||
const a = newToken();
|
||||
expect(isTokenShaped(a)).toBe(true);
|
||||
expect(a).not.toBe(newToken());
|
||||
});
|
||||
|
||||
it('rejects anything else before touching the store', async () => {
|
||||
expect(isTokenShaped('../../etc/passwd')).toBe(false);
|
||||
expect(isTokenShaped('short')).toBe(false);
|
||||
expect(await findSessionByToken('short')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiry', () => {
|
||||
it('is inclusive of the last day', () => {
|
||||
const session = { expiresOn: '2026-05-12' };
|
||||
expect(isExpired(session, new Date('2026-05-12T22:00:00'))).toBe(false);
|
||||
expect(isExpired(session, new Date('2026-05-13T00:00:01'))).toBe(true);
|
||||
});
|
||||
|
||||
it('never expires without a date', () => {
|
||||
expect(isExpired({}, new Date('2999-01-01'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('requires a client name and an album UUID', () => {
|
||||
expect(sessionInputSchema.safeParse({ clientName: ' ', albumId: ALBUM }).success).toBe(false);
|
||||
expect(sessionInputSchema.safeParse({ clientName: 'A', albumId: 'nope' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed dates and limits', () => {
|
||||
expect(sessionInputSchema.safeParse({ ...input(), expiresOn: '12.05.2026' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(sessionInputSchema.safeParse({ ...input(), downloadLimit: 0 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let a patch move a link to another album', () => {
|
||||
const parsed = sessionPatchSchema.parse({ albumId: ALBUM, clientName: 'B' });
|
||||
expect(parsed).not.toHaveProperty('albumId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('store', () => {
|
||||
it('creates, finds and lists a session, written 0600', async () => {
|
||||
const session = await createSession(input({ download: 'selection', downloadLimit: 2 }));
|
||||
expect(session.selection).toEqual([]);
|
||||
expect(session.downloadsUsed).toBe(0);
|
||||
expect(await findSessionByToken(session.token)).toMatchObject({ id: session.id });
|
||||
expect(await getSessionById(session.id)).toMatchObject({ clientName: 'Anna' });
|
||||
expect(await listSessions()).toHaveLength(1);
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(storePath()).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it('saves and de-duplicates a selection', async () => {
|
||||
const { token } = await createSession(input());
|
||||
const saved = await saveSelection(token, ['a', 'b', 'a']);
|
||||
expect(saved.selection).toEqual(['a', 'b']);
|
||||
expect(saved.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('serialises concurrent saves instead of losing one', async () => {
|
||||
const one = await createSession(input({ clientName: 'One' }));
|
||||
const two = await createSession(input({ clientName: 'Two' }));
|
||||
await Promise.all([saveSelection(one.token, ['x']), saveSelection(two.token, ['y'])]);
|
||||
expect((await getSessionById(one.id))?.selection).toEqual(['x']);
|
||||
expect((await getSessionById(two.id))?.selection).toEqual(['y']);
|
||||
});
|
||||
|
||||
it('locks the selection on submit, and only the first submit counts', async () => {
|
||||
const { token, id } = await createSession(input());
|
||||
await saveSelection(token, ['a']);
|
||||
const first = await submitSelection(token);
|
||||
const second = await submitSelection(token);
|
||||
expect(first.firstSubmit).toBe(true);
|
||||
expect(second.firstSubmit).toBe(false);
|
||||
await expect(saveSelection(token, ['b'])).rejects.toMatchObject({ code: 'submitted' });
|
||||
expect(sessionState((await getSessionById(id)) as ProofSession)).toBe('submitted');
|
||||
|
||||
// The photographer can reopen it.
|
||||
await updateSession(id, { reopen: true });
|
||||
expect((await saveSelection(token, ['b'])).selection).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('refuses every client action on an expired link', async () => {
|
||||
const { token } = await createSession(input({ expiresOn: '2000-01-01' }));
|
||||
await expect(saveSelection(token, ['a'])).rejects.toMatchObject({ code: 'expired' });
|
||||
await expect(submitSelection(token)).rejects.toMatchObject({ code: 'expired' });
|
||||
});
|
||||
|
||||
it('counts downloads against the limit', async () => {
|
||||
const created = await createSession(input({ download: 'album', downloadLimit: 2 }));
|
||||
await claimDownload(created.token);
|
||||
const after = await claimDownload(created.token);
|
||||
expect(downloadsRemaining(after)).toBe(0);
|
||||
await expect(claimDownload(created.token)).rejects.toMatchObject({ code: 'limit' });
|
||||
|
||||
await updateSession(created.id, { resetDownloads: true });
|
||||
expect(downloadsRemaining(await claimDownload(created.token))).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses to submit an empty selection, decided under the write lock', async () => {
|
||||
const { token, id } = await createSession(input());
|
||||
await saveSelection(token, ['a']);
|
||||
// A save of [] landing between the route's snapshot and the submit.
|
||||
await saveSelection(token, []);
|
||||
await expect(submitSelection(token)).rejects.toMatchObject({ code: 'empty' });
|
||||
expect((await getSessionById(id))?.submittedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-checks the download scope under the lock', async () => {
|
||||
const { token, id } = await createSession(input({ download: 'album' }));
|
||||
// The photographer narrows the link while a request is on its way.
|
||||
await updateSession(id, { download: 'selection' });
|
||||
await expect(claimDownload(token, 'album')).rejects.toMatchObject({ code: 'not-found' });
|
||||
await expect(claimDownload(token, 'selection')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('refuses downloads on a link that offers none', async () => {
|
||||
const { token } = await createSession(input());
|
||||
await expect(claimDownload(token)).rejects.toBeInstanceOf(ProofError);
|
||||
});
|
||||
|
||||
it('clears optional fields with null in a patch', async () => {
|
||||
const { id } = await createSession(
|
||||
input({ expiresOn: '2030-01-01', download: 'album', downloadLimit: 3 }),
|
||||
);
|
||||
const patched = await updateSession(id, { expiresOn: null, downloadLimit: null });
|
||||
expect(patched.expiresOn).toBeUndefined();
|
||||
expect(patched.downloadLimit).toBeUndefined();
|
||||
expect(downloadsRemaining(patched)).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes a link, which stops it working', async () => {
|
||||
const { id, token } = await createSession(input());
|
||||
await deleteSession(id);
|
||||
expect(await findSessionByToken(token)).toBeNull();
|
||||
await expect(deleteSession(id)).rejects.toMatchObject({ code: 'not-found' });
|
||||
});
|
||||
|
||||
it('does not overwrite a store it cannot parse', async () => {
|
||||
fs.mkdirSync(path.dirname(storePath()), { recursive: true });
|
||||
fs.writeFileSync(storePath(), '{ not json');
|
||||
await expect(createSession(input())).rejects.toThrow();
|
||||
expect(fs.readFileSync(storePath(), 'utf8')).toBe('{ not json');
|
||||
// …and the queue is not stuck behind the failure.
|
||||
fs.writeFileSync(storePath(), '{"sessions":[]}');
|
||||
await expect(createSession(input())).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
76
lib/__tests__/proofWebhook.test.ts
Normal file
76
lib/__tests__/proofWebhook.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { buildWebhookPayload, notifySubmitted, webhookUrl } from '../proofWebhook';
|
||||
import type { ProofSession } from '../proofSessions';
|
||||
|
||||
const session: ProofSession = {
|
||||
id: 'abc123',
|
||||
token: 'x'.repeat(32),
|
||||
clientName: 'Anna & Ben',
|
||||
albumId: '11111111-1111-1111-1111-111111111111',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
download: 'none',
|
||||
downloadsUsed: 0,
|
||||
selection: ['a', 'b'],
|
||||
submittedAt: '2026-02-01T00:00:00Z',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('webhookUrl', () => {
|
||||
it('accepts http(s) only', () => {
|
||||
expect(webhookUrl('https://ntfy.example/x')).toBe('https://ntfy.example/x');
|
||||
expect(webhookUrl('ftp://x')).toBeNull();
|
||||
expect(webhookUrl('not a url')).toBeNull();
|
||||
expect(webhookUrl('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebhookPayload', () => {
|
||||
it('speaks Discord, Slack and Gotify at once, and never carries the client token', () => {
|
||||
const payload = buildWebhookPayload(session, 'Wedding', 'https://folio.example');
|
||||
expect(payload.content).toContain('Anna & Ben');
|
||||
expect(payload.text).toContain('2 photos');
|
||||
expect(payload.title).toBeTruthy();
|
||||
expect(payload.message).toContain('"Wedding"');
|
||||
expect(payload.proofing.adminUrl).toBe('https://folio.example/admin/proofing?session=abc123');
|
||||
expect(JSON.stringify(payload)).not.toContain(session.token);
|
||||
});
|
||||
|
||||
it('works without a site URL', () => {
|
||||
expect(buildWebhookPayload(session, 'Wedding', null).proofing.adminUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifySubmitted', () => {
|
||||
it('does nothing without a URL', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await notifySubmitted(session, 'Wedding', null);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts JSON to the configured URL', async () => {
|
||||
vi.stubEnv('PROOFING_WEBHOOK_URL', 'https://hooks.example/abc');
|
||||
const fetchMock = vi.fn(async () => new Response('ok'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await notifySubmitted(session, 'Wedding', null);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe('https://hooks.example/abc');
|
||||
expect(JSON.parse(init.body as string).event).toBe('proofing.submitted');
|
||||
});
|
||||
|
||||
it('swallows a failing webhook', async () => {
|
||||
vi.stubEnv('PROOFING_WEBHOOK_URL', 'https://hooks.example/abc');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => Promise.reject(new Error('down'))),
|
||||
);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await expect(notifySubmitted(session, 'Wedding', null)).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ vi.mock('@/lib/env', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveProofing } from '@/lib/config';
|
||||
import { resolveProofing, resolveProofingEmail } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Client proofing hands a gallery to a client and takes their picks back, so
|
||||
@@ -48,3 +48,33 @@ describe('resolveProofing', () => {
|
||||
expect(resolveProofing({ proofing: false }, true)).not.toBe(resolveProofing(undefined, true));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The proofing dialog's "email to photographer" needs a recipient (#736): the
|
||||
* footer contact email by default, overridable with `proofing.email`. Neither
|
||||
* set means no recipient, and the dialog hides the button rather than opening
|
||||
* a compose with an empty To:.
|
||||
*/
|
||||
describe('resolveProofingEmail', () => {
|
||||
it('prefers the proofing address over the footer contact email', () => {
|
||||
expect(resolveProofingEmail('proofs@example.com', 'hello@example.com')).toBe(
|
||||
'proofs@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the footer contact email', () => {
|
||||
expect(resolveProofingEmail(undefined, 'hello@example.com')).toBe('hello@example.com');
|
||||
expect(resolveProofingEmail('', 'hello@example.com')).toBe('hello@example.com');
|
||||
expect(resolveProofingEmail(' ', 'hello@example.com')).toBe('hello@example.com');
|
||||
});
|
||||
|
||||
it('is undefined when neither is set, so the button is hidden', () => {
|
||||
expect(resolveProofingEmail(undefined, undefined)).toBeUndefined();
|
||||
expect(resolveProofingEmail('', '')).toBeUndefined();
|
||||
expect(resolveProofingEmail(' ', ' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(resolveProofingEmail(' proofs@example.com ', undefined)).toBe('proofs@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('@/lib/config', () => ({
|
||||
import { isSiteUnlocked } from '@/lib/auth';
|
||||
import { cdnOrigin } from '@/lib/cdn';
|
||||
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
|
||||
import { proxy, config, isKnownMissing } from '@/proxy';
|
||||
import { proxy, config, isKnownMissing, TOP_LEVEL_ROUTES } from '@/proxy';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -307,6 +307,11 @@ describe('isKnownMissing', () => {
|
||||
expect(isKnownMissing('/journal/..%2Fsettings', contentDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a malformed escape in a journal slug as missing instead of throwing', () => {
|
||||
expect(isKnownMissing('/journal/%E0', contentDir)).toBe(true);
|
||||
expect(proxy(new NextRequest('https://example.com/journal/%E0')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('leaves album and subpage slugs to the page, which needs Immich to decide', () => {
|
||||
expect(isKnownMissing('/japan', contentDir)).toBe(false);
|
||||
expect(isKnownMissing('/japan/tokyo', contentDir)).toBe(false);
|
||||
@@ -317,6 +322,60 @@ describe('isKnownMissing', () => {
|
||||
expect(isKnownMissing('/contact', contentDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('knows paths deeper than the catch-all serves', () => {
|
||||
expect(isKnownMissing('/x/y/z', contentDir)).toBe(true);
|
||||
expect(isKnownMissing('/japan/tokyo/extra/', contentDir)).toBe(true);
|
||||
// Deep paths below a fixed route are that route's to answer.
|
||||
expect(isKnownMissing('/admin/settings/general', contentDir)).toBe(false);
|
||||
expect(isKnownMissing('/admin/journal/kyoto', contentDir)).toBe(false);
|
||||
expect(isKnownMissing('/proof/abc/def', contentDir)).toBe(false);
|
||||
// Next's internals and dotfiles are not the catch-all's either.
|
||||
expect(isKnownMissing('/_next/data/build/x.json', contentDir)).toBe(false);
|
||||
expect(isKnownMissing('/.well-known/appspecific/x.json', contentDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('lists every top-level route directory under app/', () => {
|
||||
// A route directory missing from the list would have its deep paths
|
||||
// answered 404 while the page renders normally.
|
||||
const appDir = path.join(process.cwd(), 'app');
|
||||
const hasRoute = (dir: string): boolean =>
|
||||
fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((entry) =>
|
||||
entry.isDirectory()
|
||||
? hasRoute(path.join(dir, entry.name))
|
||||
: /^(page|route)\.(tsx?|jsx?)$/.test(entry.name),
|
||||
);
|
||||
const routeDirs = fs
|
||||
.readdirSync(appDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name !== '[...path]')
|
||||
.filter((entry) => hasRoute(path.join(appDir, entry.name)))
|
||||
.map((entry) => entry.name);
|
||||
expect(routeDirs.length).toBeGreaterThan(5);
|
||||
for (const name of routeDirs) expect(TOP_LEVEL_ROUTES.has(name), name).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* #704 answered with NextResponse.rewrite(request.nextUrl, { status: 404 }).
|
||||
* NextURL reports 127.0.0.1 as "localhost", so behind `next start -H
|
||||
* 127.0.0.1` the destination's origin no longer matched the server's and
|
||||
* Next proxied it as an external URL to localhost — ::1, where nothing
|
||||
* listened: a 30 s hang and a 500 for every known-missing path.
|
||||
*/
|
||||
it.each(['/journal/nope', '/contact', '/x/y/z'])(
|
||||
'answers %s with a 404 on the same request, not a rewrite to another origin',
|
||||
(pathname) => {
|
||||
const res = proxy(new NextRequest(`http://127.0.0.1:3591${pathname}`));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
|
||||
expect(res.headers.get('x-middleware-next')).toBe('1');
|
||||
// The page still gets its nonce, and the policy still matches it.
|
||||
const nonce = res.headers.get('x-middleware-request-x-nonce');
|
||||
expect(nonce).toBeTruthy();
|
||||
expect(res.headers.get('Content-Security-Policy')).toContain(`'nonce-${nonce}'`);
|
||||
},
|
||||
);
|
||||
|
||||
it('makes proxy() answer 404 with the policy still set', () => {
|
||||
const res = proxy(new NextRequest('https://example.com/contact'));
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
@@ -123,8 +123,21 @@ describe('publicPaths', () => {
|
||||
site({
|
||||
journalEnabled: true,
|
||||
journal: [
|
||||
{ slug: 'published', draft: false },
|
||||
{ slug: 'wip', draft: true },
|
||||
{ slug: 'published', draft: false, isProtected: false },
|
||||
{ slug: 'wip', draft: true, isProtected: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(paths).toEqual(['/', '/journal', '/journal/published']);
|
||||
});
|
||||
|
||||
it('omits a password-protected journal entry, as the journal index does', () => {
|
||||
const paths = publicPaths(
|
||||
site({
|
||||
journalEnabled: true,
|
||||
journal: [
|
||||
{ slug: 'published', draft: false, isProtected: false },
|
||||
{ slug: 'private', draft: false, isProtected: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -133,7 +146,10 @@ describe('publicPaths', () => {
|
||||
|
||||
it('omits journal entries entirely when the journal is off', () => {
|
||||
const paths = publicPaths(
|
||||
site({ journalEnabled: false, journal: [{ slug: 'published', draft: false }] }),
|
||||
site({
|
||||
journalEnabled: false,
|
||||
journal: [{ slug: 'published', draft: false, isProtected: false }],
|
||||
}),
|
||||
);
|
||||
expect(paths).toEqual(['/']);
|
||||
});
|
||||
@@ -150,12 +166,38 @@ describe('publicPaths', () => {
|
||||
expect(paths).toEqual([...new Set(paths)]);
|
||||
});
|
||||
|
||||
it('lists a published content page, whether or not it is in the menu (#722)', () => {
|
||||
const paths = publicPaths(
|
||||
site({ pages: [{ slug: 'pricing', draft: false, isProtected: false }] }),
|
||||
);
|
||||
expect(paths).toEqual(['/', '/pricing']);
|
||||
});
|
||||
|
||||
it('leaves out a draft content page and a password-protected one', () => {
|
||||
const paths = publicPaths(
|
||||
site({
|
||||
pages: [
|
||||
{ slug: 'draft', draft: true, isProtected: false },
|
||||
{ slug: 'secret', draft: false, isProtected: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(paths).toEqual(['/']);
|
||||
});
|
||||
|
||||
it('lists no content page on a locked site', () => {
|
||||
const paths = publicPaths(
|
||||
site({ siteLocked: true, pages: [{ slug: 'pricing', draft: false, isProtected: false }] }),
|
||||
);
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits only absolute-looking, single-slash paths', () => {
|
||||
const paths = publicPaths(
|
||||
site({
|
||||
aboutEnabled: true,
|
||||
journalEnabled: true,
|
||||
journal: [{ slug: 'story', draft: false }],
|
||||
journal: [{ slug: 'story', draft: false, isProtected: false }],
|
||||
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
|
||||
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
|
||||
}),
|
||||
|
||||
300
lib/__tests__/publishedAssets.test.ts
Normal file
300
lib/__tests__/publishedAssets.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* GHSA-gfh4-6275-9gqv: the media routes serve only assets the site shows. Each
|
||||
* source that puts an asset on a public page must land in the set, or that
|
||||
* page loses its photos; nothing else may.
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
config: {
|
||||
albums: ['album-pub', 'album-offline'],
|
||||
standaloneAlbums: ['album-pub'],
|
||||
heroImages: ['hero-1'],
|
||||
albumHeroImages: {
|
||||
'album-pub': 'album-hero',
|
||||
'album-offline': 'offline-hero',
|
||||
} as Record<string, string>,
|
||||
subpages: [
|
||||
{ albumIds: [], enabled: true, essayText: '' },
|
||||
// Taken offline with `enabled: false`: its albums stay on the allowlist.
|
||||
{
|
||||
albumIds: ['album-offline'],
|
||||
enabled: false,
|
||||
essayText: '',
|
||||
},
|
||||
] as Array<{ albumIds: string[]; enabled: boolean; essayText?: string }>,
|
||||
cacheTtl: 300,
|
||||
},
|
||||
albums: {
|
||||
'album-pub': { albumThumbnailAssetId: 'thumb', assets: [{ id: 'a1' }, { id: 'a2' }] },
|
||||
'album-offline': { albumThumbnailAssetId: 'offline-thumb', assets: [{ id: 'offline-1' }] },
|
||||
} as Record<string, { albumThumbnailAssetId: string | null; assets: { id: string }[] }>,
|
||||
raw: {
|
||||
'album-journal': [{ id: 'j-album-1' }],
|
||||
'album-essay': [{ id: 'essay-1' }],
|
||||
'album-offline-essay': [{ id: 'offline-essay-1' }],
|
||||
'album-page': [{ id: 'page-album-1' }],
|
||||
} as Record<string, { id: string }[]>,
|
||||
proofAlbums: {
|
||||
'album-proof': { albumThumbnailAssetId: null, assets: [{ id: 'proof-1' }] },
|
||||
'album-expired': { albumThumbnailAssetId: null, assets: [{ id: 'expired-1' }] },
|
||||
} as Record<string, { albumThumbnailAssetId: string | null; assets: { id: string }[] }>,
|
||||
builds: 0,
|
||||
mtime: 1,
|
||||
/** While set, a proofing album lookup reads its answer, then waits for it. */
|
||||
gate: null as Promise<void> | null,
|
||||
onGate: null as (() => void) | null,
|
||||
}));
|
||||
|
||||
vi.mock('../config', () => ({ getConfig: () => state.config }));
|
||||
|
||||
vi.mock('../immich', () => ({
|
||||
immich: {
|
||||
getAlbum: async (id: string) => {
|
||||
state.builds += id === 'album-pub' ? 1 : 0;
|
||||
return state.albums[id] ?? null;
|
||||
},
|
||||
getProofingAlbum: async (id: string) => {
|
||||
const album = state.proofAlbums[id];
|
||||
const answer = album ? { ...album, assets: [...album.assets] } : null;
|
||||
if (state.gate) {
|
||||
state.onGate?.();
|
||||
await state.gate;
|
||||
}
|
||||
return answer;
|
||||
},
|
||||
getAlbumAssetsRaw: async (id: string) => state.raw[id] ?? [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../admin/journal-service', () => ({
|
||||
listJournalEntries: async () => [{ slug: 'trip' }, { slug: 'draft' }],
|
||||
readJournalEntry: async (slug: string) => ({
|
||||
slug,
|
||||
rawMarkdown: '',
|
||||
parsed:
|
||||
slug === 'trip'
|
||||
? {
|
||||
frontmatter: { coverAssetId: 'j-cover' },
|
||||
blocks: [
|
||||
{ type: 'photo', assetId: 'j-photo', layout: 'wide' },
|
||||
{ type: 'album', albumId: 'album-journal', layout: 'grid' },
|
||||
],
|
||||
}
|
||||
: { frontmatter: {}, blocks: [{ type: 'photo', assetId: 'draft-photo', layout: 'wide' }] },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../admin/pages-service', () => ({
|
||||
listPages: async () => [{ slug: 'pricing', frontmatter: {} }],
|
||||
readPage: async (slug: string) => ({
|
||||
slug,
|
||||
rawMarkdown: '',
|
||||
parsed: {
|
||||
frontmatter: { draft: true },
|
||||
blocks: [
|
||||
{ type: 'photo', assetId: 'page-photo', layout: 'wide' },
|
||||
{ type: 'album', albumId: 'album-page', layout: 'grid' },
|
||||
{
|
||||
type: 'map',
|
||||
line: true,
|
||||
items: [{ kind: 'photo', assetId: 'page-map-photo' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../journal', async (orig) => {
|
||||
const actual = await orig<typeof import('../journal')>();
|
||||
return {
|
||||
...actual,
|
||||
parseJournalMarkdown: (md: string) => {
|
||||
const albumId = /album:([\w-]+)/.exec(md)?.[1];
|
||||
return albumId
|
||||
? { frontmatter: {}, blocks: [{ type: 'album', albumId, layout: 'grid' }] }
|
||||
: actual.parseJournalMarkdown(md);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../proofSessions', () => ({
|
||||
listSessions: async () => [
|
||||
{ albumId: 'album-proof', expiresOn: undefined },
|
||||
{ albumId: 'album-expired', expiresOn: '2000-01-01' },
|
||||
],
|
||||
isExpired: (s: { expiresOn?: string }) => s.expiresOn === '2000-01-01',
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
readFile: async () => '---\nportrait: portrait-1\n---\nBio',
|
||||
stat: async () => ({ mtimeMs: state.mtime }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { isPublishedAsset, resetPublishedAssetsForTest } from '../publishedAssets';
|
||||
|
||||
beforeEach(() => {
|
||||
resetPublishedAssetsForTest();
|
||||
state.builds = 0;
|
||||
});
|
||||
|
||||
describe('isPublishedAsset', () => {
|
||||
it.each([
|
||||
['an album photo', 'a1'],
|
||||
['an album thumbnail', 'thumb'],
|
||||
['a homepage hero', 'hero-1'],
|
||||
['an album hero', 'album-hero'],
|
||||
['the about portrait', 'portrait-1'],
|
||||
['a journal cover', 'j-cover'],
|
||||
['a journal photo block', 'j-photo'],
|
||||
["a journal album block's photo", 'j-album-1'],
|
||||
['a draft entry photo (its page gate hides it)', 'draft-photo'],
|
||||
["an inline essay's album block photo", 'essay-1'],
|
||||
['a content page photo block (draft included)', 'page-photo'],
|
||||
["a content page album block's photo", 'page-album-1'],
|
||||
['a photo of an open proofing link', 'proof-1'],
|
||||
])('serves %s', async (_label, id) => {
|
||||
expect(await isPublishedAsset(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a photo of an expired proofing link', async () => {
|
||||
expect(await isPublishedAsset('expired-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a photo only a content page map block names (maps do not render on pages)', async () => {
|
||||
expect(await isPublishedAsset('page-map-photo')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a photo of its album', 'offline-1'],
|
||||
['its album thumbnail', 'offline-thumb'],
|
||||
['its album hero', 'offline-hero'],
|
||||
["an album block's photo in its inline essay", 'offline-essay-1'],
|
||||
])('refuses %s once a subpage is offline', async (_label, id) => {
|
||||
expect(await isPublishedAsset(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses any other asset in the library', async () => {
|
||||
expect(await isPublishedAsset('never-published')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds once and rebuilds on a miss at most every ten seconds', async () => {
|
||||
await isPublishedAsset('a1');
|
||||
await isPublishedAsset('a2');
|
||||
expect(state.builds).toBe(1);
|
||||
|
||||
await isPublishedAsset('unknown-1'); // forced rebuild
|
||||
await isPublishedAsset('unknown-2'); // throttled
|
||||
expect(state.builds).toBe(2);
|
||||
});
|
||||
|
||||
it('rebuilds at once when a content file changes, e.g. a revoked link', async () => {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(true);
|
||||
delete state.proofAlbums['album-proof'].assets[0];
|
||||
state.proofAlbums['album-proof'].assets = [];
|
||||
state.mtime += 1; // proofing.json rewritten by the admin
|
||||
try {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(false);
|
||||
} finally {
|
||||
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The files used to be checked at most every 2 s. A request in that window
|
||||
* after a save was answered from the set before it — with `immutable`, so
|
||||
* the browser kept the photo.
|
||||
*/
|
||||
it('sees a save made milliseconds after the previous check', async () => {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(true);
|
||||
expect(await isPublishedAsset('a1')).toBe(true); // a check a moment ago
|
||||
state.proofAlbums['album-proof'].assets = [];
|
||||
state.mtime += 1;
|
||||
try {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(false);
|
||||
} finally {
|
||||
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A request that finds a build already running joins it. When that build
|
||||
* read the content before the save, its set must not answer for a request
|
||||
* that saw the save.
|
||||
*/
|
||||
it('does not answer from a build that read the content before the save', async () => {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(true);
|
||||
|
||||
let release!: () => void;
|
||||
state.gate = new Promise<void>((r) => (release = r));
|
||||
const reached = new Promise<void>((r) => (state.onGate = r));
|
||||
state.mtime += 1; // an unrelated save starts a rebuild…
|
||||
const first = isPublishedAsset('a1');
|
||||
await reached; // …which has read the proofing album and is waiting on Immich
|
||||
|
||||
state.proofAlbums['album-proof'].assets = [];
|
||||
state.mtime += 1; // the link is revoked
|
||||
const second = isPublishedAsset('proof-1');
|
||||
|
||||
state.gate = null;
|
||||
state.onGate = null;
|
||||
release();
|
||||
try {
|
||||
expect(await first).toBe(true);
|
||||
expect(await second).toBe(false);
|
||||
} finally {
|
||||
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The first view of a newly published album: a burst of image requests for
|
||||
* photos the set has never seen. Only the first one rebuilt; the rest hit
|
||||
* the rebuild throttle and were refused while that rebuild was still
|
||||
* running — 11 of 13 photos broken until a reload.
|
||||
*/
|
||||
describe('a burst of requests for a newly published album', () => {
|
||||
const NEW = Array.from({ length: 13 }, (_, i) => ({ id: `fresh-${i}` }));
|
||||
|
||||
beforeEach(async () => {
|
||||
await isPublishedAsset('a1'); // built, and checked a moment ago
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }];
|
||||
});
|
||||
|
||||
it('serves every photo when the save changed a content file', async () => {
|
||||
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW];
|
||||
state.mtime += 1; // gallery.yaml saved by the admin
|
||||
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
|
||||
expect(answers).toEqual(NEW.map(() => true));
|
||||
});
|
||||
|
||||
it('serves every photo when only Immich changed (misses share one rebuild)', async () => {
|
||||
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW];
|
||||
const before = state.builds;
|
||||
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
|
||||
expect(answers).toEqual(NEW.map(() => true));
|
||||
expect(state.builds - before).toBe(1);
|
||||
});
|
||||
|
||||
it('still refuses what the rebuild did not publish', async () => {
|
||||
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
|
||||
expect(answers).toEqual(NEW.map(() => false));
|
||||
});
|
||||
});
|
||||
|
||||
it('picks up an asset published since the last build', async () => {
|
||||
await isPublishedAsset('a1');
|
||||
state.albums['album-pub'].assets.push({ id: 'new-photo' });
|
||||
try {
|
||||
expect(await isPublishedAsset('new-photo')).toBe(true);
|
||||
} finally {
|
||||
state.albums['album-pub'].assets.pop();
|
||||
}
|
||||
});
|
||||
});
|
||||
64
lib/__tests__/refusal-palette.test.ts
Normal file
64
lib/__tests__/refusal-palette.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { THEME_PRESETS } from '../config/theme';
|
||||
|
||||
/**
|
||||
* The download refusal page cannot load the site's stylesheets, so it carries
|
||||
* each preset's surface and text colours itself. This keeps that copy honest:
|
||||
* it resolves the same custom properties from app/tokens.css the way the
|
||||
* cascade does, and fails when a preset changes a colour the copy still has.
|
||||
*/
|
||||
|
||||
vi.mock('../immich', () => ({ immich: {} }));
|
||||
|
||||
import { REFUSAL_PALETTES } from '../zipArchive';
|
||||
|
||||
const css = fs
|
||||
.readFileSync(path.join(__dirname, '../../app/tokens.css'), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
/** Custom properties declared by the rule with exactly this selector. */
|
||||
function block(selector: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
||||
if (m[1].trim() !== selector) continue;
|
||||
for (const d of m[2].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) out[d[1]] = d[2].trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What `<html data-preset=… data-theme=…>` resolves, in cascade order. */
|
||||
function resolve(preset: string, mode: 'dark' | 'light') {
|
||||
const light = mode === 'light';
|
||||
const vars = {
|
||||
...block(':root'),
|
||||
...(light ? block("[data-theme='light']") : {}),
|
||||
...block(`[data-preset='${preset}']`),
|
||||
...(light ? block(`[data-preset='${preset}'][data-theme='light']`) : {}),
|
||||
};
|
||||
return {
|
||||
bg: vars['--bg-primary'],
|
||||
text: vars['--text-primary'],
|
||||
muted: vars['--text-secondary'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('refusal page palette', () => {
|
||||
const cases = Object.keys(THEME_PRESETS).flatMap((preset) =>
|
||||
(['dark', 'light'] as const).map((mode) => [preset, mode] as const),
|
||||
);
|
||||
|
||||
it.each(cases)('%s in %s mode matches app/tokens.css', (preset, mode) => {
|
||||
const palette = REFUSAL_PALETTES[preset] ?? REFUSAL_PALETTES.default;
|
||||
expect(palette[mode]).toEqual(resolve(preset, mode));
|
||||
});
|
||||
|
||||
it('no theme stylesheet redefines the colours the copy is taken from', () => {
|
||||
const dir = path.join(__dirname, '../../app/themes');
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
const text = fs.readFileSync(path.join(dir, file), 'utf8');
|
||||
expect(text, file).not.toMatch(/--(bg-primary|text-primary|text-secondary)\s*:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
53
lib/__tests__/settingValues.test.ts
Normal file
53
lib/__tests__/settingValues.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateSettingValues } from '../config/settingValues';
|
||||
import { resolveTheme } from '../config/theme';
|
||||
import { normaliseSiteUrl } from '../siteUrl';
|
||||
|
||||
const fields = (input: unknown) => validateSettingValues(input).map((e) => e.field);
|
||||
|
||||
/**
|
||||
* The save route and the doctor refuse exactly what the site would ignore, no
|
||||
* more (QA A-14). Each case is paired with the resolver it mirrors, so the two
|
||||
* cannot drift apart unnoticed.
|
||||
*/
|
||||
describe('validateSettingValues', () => {
|
||||
it('passes an empty or partial file', () => {
|
||||
expect(fields({})).toEqual([]);
|
||||
expect(fields({ title: 'Folio' })).toEqual([]);
|
||||
expect(fields(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an accent resolveTheme would replace, and only that', () => {
|
||||
for (const accent of ['rot', 'red', 'rgb(230 0 18)', '#12345']) {
|
||||
expect(fields({ theme: { accent } })).toEqual(['theme.accent']);
|
||||
expect(resolveTheme({ preset: 'minimal', accent }).accent).toBe(
|
||||
resolveTheme('minimal').accent,
|
||||
);
|
||||
}
|
||||
for (const accent of ['#e60012', '#FFF', ' #e60012 ', '#e60012cc', '']) {
|
||||
expect(fields({ theme: { accent } })).toEqual([]);
|
||||
}
|
||||
// A preset name alone carries no accent.
|
||||
expect(fields({ theme: 'minimal' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a site URL normaliseSiteUrl cannot use', () => {
|
||||
for (const url of ['folio.example', 'ftp://folio.example', 'javascript:alert(1)']) {
|
||||
expect(fields({ url })).toEqual(['url']);
|
||||
expect(normaliseSiteUrl(url)).toBeNull();
|
||||
}
|
||||
for (const url of ['https://folio.example', 'http://folio.example/sub/', '', ' ']) {
|
||||
expect(fields({ url })).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses grid values outside the bounds getConfig clamps to', () => {
|
||||
expect(fields({ grid: { columns: 9 } })).toEqual(['grid.columns']);
|
||||
expect(fields({ grid: { columns: 0 } })).toEqual(['grid.columns']);
|
||||
expect(fields({ grid: { columns: 2.5 } })).toEqual(['grid.columns']);
|
||||
expect(fields({ grid: { gap: 49 } })).toEqual(['grid.gap']);
|
||||
expect(fields({ grid: { gap: -1 } })).toEqual(['grid.gap']);
|
||||
expect(fields({ grid: { columns: 1, gap: 0 } })).toEqual([]);
|
||||
expect(fields({ grid: { columns: 6, gap: 48 } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
57
lib/__tests__/siteNav.test.ts
Normal file
57
lib/__tests__/siteNav.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { siteNavLinks, type SiteNavInput } from '../siteNav';
|
||||
import { getDictionary } from '../i18n';
|
||||
|
||||
const t = getDictionary('en');
|
||||
|
||||
const base: SiteNavInput = {
|
||||
menu: [
|
||||
{ key: 'sp-travel', href: '/travel', label: 'Travel' },
|
||||
{ key: 'page-prices', href: '/prices', label: 'Prices' },
|
||||
{ key: 'sp-people', href: '/people', label: 'People' },
|
||||
],
|
||||
subpageAlbumCounts: new Map([
|
||||
['travel', 3],
|
||||
['people', 1],
|
||||
]),
|
||||
albums: [{ id: 'a1', slug: 'birds', albumName: 'Birds', assetCount: 12 }],
|
||||
hasJournal: true,
|
||||
aboutEnabled: true,
|
||||
map: true,
|
||||
};
|
||||
|
||||
describe('siteNavLinks', () => {
|
||||
it('orders subpages and pages, albums, Journal, About, Map (#696)', () => {
|
||||
expect(siteNavLinks(base, t).map((l) => l.href)).toEqual([
|
||||
'/travel',
|
||||
'/prices',
|
||||
'/people',
|
||||
'/birds',
|
||||
'/journal',
|
||||
'/about',
|
||||
'/map',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves out Journal, About and Map when they are off', () => {
|
||||
const links = siteNavLinks({ ...base, hasJournal: false, aboutEnabled: false, map: false }, t);
|
||||
expect(links.map((l) => l.href)).not.toContain('/about');
|
||||
expect(links.map((l) => l.href)).not.toContain('/journal');
|
||||
expect(links.map((l) => l.href)).not.toContain('/map');
|
||||
});
|
||||
|
||||
it('attaches counts to subpages and albums only', () => {
|
||||
const byHref = new Map(siteNavLinks(base, t).map((l) => [l.href, l.count]));
|
||||
expect(byHref.get('/travel')).toBe('3 albums');
|
||||
expect(byHref.get('/people')).toBe('1 album');
|
||||
expect(byHref.get('/birds')).toBe('12 photos');
|
||||
expect(byHref.get('/prices')).toBeUndefined();
|
||||
expect(byHref.get('/about')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is empty for a site with nothing to link', () => {
|
||||
expect(
|
||||
siteNavLinks({ menu: [], albums: [], hasJournal: false, aboutEnabled: false, map: false }, t),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
48
lib/__tests__/siteShape.test.ts
Normal file
48
lib/__tests__/siteShape.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
subpages: [],
|
||||
standaloneAlbums: [],
|
||||
aboutEnabled: false,
|
||||
map: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { getAlbums: async () => [] },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
isProtected: () => false,
|
||||
isSiteLocked: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/journal-service', () => ({
|
||||
listJournalEntries: async () => [
|
||||
{ slug: 'open', frontmatter: { title: 'Open' } },
|
||||
{ slug: 'locked', frontmatter: { title: 'Locked', password: 'scrypt:salt:hash' } },
|
||||
{ slug: 'wip', frontmatter: { title: 'Draft', draft: true } },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/pages-service', () => ({
|
||||
listPages: async () => [],
|
||||
}));
|
||||
|
||||
import { buildSiteShape } from '@/lib/siteShape';
|
||||
import { publicPaths } from '@/lib/publicPages';
|
||||
|
||||
describe('buildSiteShape → publicPaths (the sitemap)', () => {
|
||||
it('lists a public journal entry', async () => {
|
||||
expect(publicPaths(await buildSiteShape())).toContain('/journal/open');
|
||||
});
|
||||
|
||||
it('leaves out a password-protected journal entry, as /journal does', async () => {
|
||||
expect(publicPaths(await buildSiteShape())).not.toContain('/journal/locked');
|
||||
});
|
||||
|
||||
it('leaves out a journal draft', async () => {
|
||||
expect(publicPaths(await buildSiteShape())).not.toContain('/journal/wip');
|
||||
});
|
||||
});
|
||||
53
lib/__tests__/slug-field.test.ts
Normal file
53
lib/__tests__/slug-field.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidSlug, sanitizeSlug, slugFieldValue } from '../journal';
|
||||
|
||||
/** What a controlled slug input shows after each keystroke of `text`. */
|
||||
function typeInto(text: string): string {
|
||||
let value = '';
|
||||
for (const ch of text) value = slugFieldValue(value + ch);
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('slugFieldValue — a slug field while it is typed', () => {
|
||||
it('lets a hyphen be typed between two words', () => {
|
||||
// sanitizeSlug() on every keystroke dropped the trailing "-", so the next
|
||||
// letter was glued on: "about-us" came out as "aboutus".
|
||||
expect(typeInto('about-us')).toBe('about-us');
|
||||
expect(typeInto('about us')).toBe('about-us');
|
||||
});
|
||||
|
||||
it('can be emptied instead of jumping to "untitled"', () => {
|
||||
expect(slugFieldValue('')).toBe('');
|
||||
});
|
||||
|
||||
it('still refuses what a slug cannot hold', () => {
|
||||
expect(slugFieldValue('Preise & Pakete!')).toBe('preise-pakete-');
|
||||
expect(slugFieldValue('--a//b')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('becomes the stored slug through sanitizeSlug()', () => {
|
||||
for (const input of ['about-', 'About Us ', ' a--b ', 'x_y']) {
|
||||
const finished = sanitizeSlug(slugFieldValue(input));
|
||||
expect(finished).toBe(sanitizeSlug(input));
|
||||
expect(isValidSlug(finished)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSlug — accented letters', () => {
|
||||
it('keeps the base letter instead of splitting the word', () => {
|
||||
// "für" used to become "f-r": every non-ASCII letter turned into a hyphen.
|
||||
expect(sanitizeSlug('Preise für Hochzeiten')).toBe('preise-fur-hochzeiten');
|
||||
expect(sanitizeSlug('Über mich')).toBe('uber-mich');
|
||||
expect(sanitizeSlug('Café Olé')).toBe('cafe-ole');
|
||||
expect(sanitizeSlug('Straße')).toBe('strasse');
|
||||
});
|
||||
|
||||
it('agrees with the slug field', () => {
|
||||
expect(typeInto('Über mich')).toBe('uber-mich');
|
||||
});
|
||||
|
||||
it('still falls back for a title without any Latin letter', () => {
|
||||
expect(sanitizeSlug('家族')).toBe('untitled');
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ describe('writeYamlFile backup failure', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('still saves a brand-new file with no backup', async () => {
|
||||
await expect(writeGalleryYaml(gallery)).resolves.toBeUndefined();
|
||||
await expect(writeGalleryYaml(gallery)).resolves.toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(fs.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
213
lib/__tests__/zipArchive.test.ts
Normal file
213
lib/__tests__/zipArchive.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Resource bounds of the ZIP-of-originals stream: how many archives one client
|
||||
* may hold open at once, that every way an archive can end gives its slot
|
||||
* back, and that a client which stops reading loses the archive (and its
|
||||
* upstream Immich body) instead of pinning both for as long as it likes.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/immich', () => ({ immich: { streamAsset: vi.fn() } }));
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
getClientIp: vi.fn((req: NextRequest) => req.headers.get('x-test-ip') ?? 'unknown'),
|
||||
}));
|
||||
|
||||
import {
|
||||
ARCHIVE_STALL_TIMEOUT_MS,
|
||||
MAX_CONCURRENT_ARCHIVES,
|
||||
__archivesInFlight,
|
||||
withArchiveSlot,
|
||||
} from '@/lib/zipArchive';
|
||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||
|
||||
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const ASSETS = [
|
||||
{ id: 'a1', type: 'IMAGE', originalFileName: 'a1.jpg' },
|
||||
{ id: 'a2', type: 'IMAGE', originalFileName: 'a2.jpg' },
|
||||
] as unknown as ImmichAsset[];
|
||||
|
||||
let ipSeq = 0;
|
||||
/** A fresh client per test, so slots can never leak from one test into another. */
|
||||
function freshIp(): string {
|
||||
ipSeq++;
|
||||
return `203.0.113.${ipSeq}`;
|
||||
}
|
||||
|
||||
const req = (ip: string) =>
|
||||
new NextRequest('http://localhost/api/download/tok/archive', { headers: { 'x-test-ip': ip } });
|
||||
|
||||
/** Start an archive of `assets` for `ip` through the slot wrapper. */
|
||||
function start(ip: string, assets: ImmichAsset[] = ASSETS) {
|
||||
return withArchiveSlot(req(ip), async (stream) => stream('Album', assets));
|
||||
}
|
||||
|
||||
/**
|
||||
* An Immich originals body that produces `chunks` × 64 KB on demand and records
|
||||
* whether it was cancelled — i.e. whether the upstream socket was let go.
|
||||
*/
|
||||
function upstream(chunks: number) {
|
||||
const state = { pulled: 0, cancelled: false };
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (state.pulled >= chunks) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
state.pulled++;
|
||||
controller.enqueue(new Uint8Array(64 * 1024).fill(state.pulled));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { state, result: { stream, contentType: 'image/jpeg', contentLength: null } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockStream.mockReset();
|
||||
mockStream.mockImplementation(async () => upstream(2).result);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('archive concurrency cap', () => {
|
||||
it(`refuses the ${MAX_CONCURRENT_ARCHIVES + 1}th open archive of one client with a 429`, async () => {
|
||||
const ip = freshIp();
|
||||
const open = [];
|
||||
for (let i = 0; i < MAX_CONCURRENT_ARCHIVES; i++) {
|
||||
const res = await start(ip);
|
||||
expect(res.status).toBe(200);
|
||||
open.push(res);
|
||||
}
|
||||
expect(__archivesInFlight(ip)).toBe(MAX_CONCURRENT_ARCHIVES);
|
||||
|
||||
const handler = vi.fn();
|
||||
const refused = await withArchiveSlot(req(ip), handler);
|
||||
expect(refused.status).toBe(429);
|
||||
expect(Number(refused.headers.get('retry-after'))).toBeGreaterThan(0);
|
||||
// Refused before the handler, so before any Immich request.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
// Another client is unaffected.
|
||||
const other = freshIp();
|
||||
const res = await start(other);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
await Promise.all([...open, res].map((r) => r.body!.cancel()));
|
||||
});
|
||||
|
||||
it('gives the slot back when the archive is read to the end', async () => {
|
||||
const ip = freshIp();
|
||||
const res = await start(ip);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
expect(bytes[0]).toBe(0x50); // 'P'
|
||||
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
||||
});
|
||||
|
||||
it('gives the slot back when the archive fails', async () => {
|
||||
const ip = freshIp();
|
||||
mockStream.mockRejectedValue(new Error('Immich went away'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = await start(ip);
|
||||
await expect(res.arrayBuffer()).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
||||
});
|
||||
|
||||
it('gives the slot back, and lets go of Immich, when the download is cancelled', async () => {
|
||||
const ip = freshIp();
|
||||
const origin = upstream(1000);
|
||||
mockStream.mockResolvedValue(origin.result);
|
||||
const res = await start(ip);
|
||||
const reader = res.body!.getReader();
|
||||
await reader.read();
|
||||
await reader.cancel();
|
||||
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
||||
await vi.waitFor(() => expect(origin.state.cancelled).toBe(true));
|
||||
});
|
||||
|
||||
it('gives the slot back when the route refuses or throws instead of streaming', async () => {
|
||||
const ip = freshIp();
|
||||
const refused = await withArchiveSlot(req(ip), async () => new Response(null, { status: 404 }));
|
||||
expect(refused.status).toBe(404);
|
||||
expect(__archivesInFlight(ip)).toBe(0);
|
||||
|
||||
await expect(
|
||||
withArchiveSlot(req(ip), async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
expect(__archivesInFlight(ip)).toBe(0);
|
||||
});
|
||||
|
||||
it('never releases a slot twice', async () => {
|
||||
const ip = freshIp();
|
||||
const first = await start(ip);
|
||||
const second = await start(ip);
|
||||
await first.arrayBuffer();
|
||||
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(1));
|
||||
// A late cancel on the finished body must not free the other archive's slot.
|
||||
await first.body?.cancel().catch(() => {});
|
||||
expect(__archivesInFlight(ip)).toBe(1);
|
||||
await second.body!.cancel();
|
||||
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive stall timeout', () => {
|
||||
it('tears down the archive and the upstream body when the client stops reading', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const ip = freshIp();
|
||||
const origin = upstream(1000);
|
||||
mockStream.mockResolvedValue(origin.result);
|
||||
|
||||
const res = await start(ip);
|
||||
const reader = res.body!.getReader();
|
||||
await reader.read();
|
||||
|
||||
// Still within the window: nothing happens.
|
||||
await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS - 1_000);
|
||||
expect(origin.state.cancelled).toBe(false);
|
||||
expect(__archivesInFlight(ip)).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => expect(origin.state.cancelled).toBe(true));
|
||||
expect(__archivesInFlight(ip)).toBe(0);
|
||||
// The response is ended with an error rather than left open. The one chunk
|
||||
// queued before the stall may still be handed out first.
|
||||
await expect(
|
||||
(async () => {
|
||||
for (;;) if ((await reader.read()).done) return;
|
||||
})(),
|
||||
).rejects.toThrow(/stalled/);
|
||||
});
|
||||
|
||||
it('does not cut a slow download that keeps reading', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
const ip = freshIp();
|
||||
const origin = upstream(128);
|
||||
mockStream.mockResolvedValue(origin.result);
|
||||
|
||||
const res = await start(ip, [ASSETS[0]]);
|
||||
const reader = res.body!.getReader();
|
||||
let bytes = 0;
|
||||
let elapsed = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
// Well under the stall timeout per read, far over it in total.
|
||||
await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS / 2);
|
||||
elapsed += ARCHIVE_STALL_TIMEOUT_MS / 2;
|
||||
}
|
||||
|
||||
expect(elapsed).toBeGreaterThan(ARCHIVE_STALL_TIMEOUT_MS * 3);
|
||||
expect(bytes).toBeGreaterThan(128 * 64 * 1024);
|
||||
expect(origin.state.cancelled).toBe(false);
|
||||
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
||||
});
|
||||
});
|
||||
42
lib/admin/assetSelection.ts
Normal file
42
lib/admin/assetSelection.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Selection state of the asset picker's multi-select mode (#602), as plain
|
||||
* functions so it can be tested without rendering the picker.
|
||||
*
|
||||
* The selection is an ordered list: the order you click in is the order the
|
||||
* photos land in (hero list, photo grid), so a Set would lose information.
|
||||
*/
|
||||
|
||||
export interface SelectionRules {
|
||||
/** Assets the caller already uses; they cannot be picked again. */
|
||||
disabled?: ReadonlySet<string>;
|
||||
/** Most assets the caller can take; undefined means no limit. */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection after toggling `id`: removed when present, appended when not,
|
||||
* unchanged when it is disabled or the limit is reached. With `max: 1` a new
|
||||
* pick replaces the old one, which is how a single slot should feel.
|
||||
*/
|
||||
export function toggleSelection(
|
||||
selected: readonly string[],
|
||||
id: string,
|
||||
rules: SelectionRules = {},
|
||||
): string[] {
|
||||
if (selected.includes(id)) return selected.filter((s) => s !== id);
|
||||
if (rules.disabled?.has(id)) return [...selected];
|
||||
if (rules.max === 1) return [id];
|
||||
if (rules.max !== undefined && selected.length >= rules.max) return [...selected];
|
||||
return [...selected, id];
|
||||
}
|
||||
|
||||
/** Whether another asset can still be added without deselecting one first. */
|
||||
export function canSelectMore(selected: readonly string[], max?: number): boolean {
|
||||
return max === undefined || max === 1 || selected.length < max;
|
||||
}
|
||||
|
||||
/** Client-side filter for lists the server returns whole (an album's assets). */
|
||||
export function matchesQuery(asset: { originalFileName: string }, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q === '' || asset.originalFileName.toLowerCase().includes(q);
|
||||
}
|
||||
127
lib/admin/contentVersion.ts
Normal file
127
lib/admin/contentVersion.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Concurrent-edit detection for the admin's content files (#601).
|
||||
*
|
||||
* Every editor holds a full snapshot of the file it edits and PUTs all of it,
|
||||
* so without a check the tab that saves second silently reverts whatever the
|
||||
* first one changed — as does a panel left open across a hand edit or a backup
|
||||
* restore. Each GET therefore hands out a version of the file it read, the PUT
|
||||
* sends it back in `If-Match`, and the write is refused with 409 when the file
|
||||
* on disk is no longer that version.
|
||||
*
|
||||
* The version is a hash of the bytes, not the mtime: the mtime has a
|
||||
* resolution the filesystem chooses (a second on some), survives a copy that
|
||||
* changes the content (`cp -p`, a restore from a backup of equal age) and
|
||||
* changes on a `touch` that does not. The files are small, so hashing one per
|
||||
* save costs nothing that matters.
|
||||
*
|
||||
* A PUT without `If-Match` is written as before. That keeps an older admin tab
|
||||
* (loaded before the upgrade) and scripts working, and the check is a guard
|
||||
* against accidents, not an access control: an operator who wants to overwrite
|
||||
* can always reload and save. `If-Match: *` means the same — write regardless.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/** The version of a file that does not exist. A save expecting it creates the file. */
|
||||
export const ABSENT_VERSION = 'absent';
|
||||
|
||||
/**
|
||||
* Version of some content; null content means "no file". A string is hashed
|
||||
* as its UTF-8 bytes, which is what a save writes — so it fits content about
|
||||
* to be written, not text decoded from a file (see readVersioned).
|
||||
*/
|
||||
export function versionOf(content: string | Buffer | null): string {
|
||||
if (content === null) return ABSENT_VERSION;
|
||||
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a text file together with the version of its bytes, for an editor's
|
||||
* GET. The version has to come from the bytes, never from the decoded text:
|
||||
* a file that is not valid UTF-8 (a Latin-1 "Über" from a hand edit) decodes
|
||||
* lossily, the text re-encodes to other bytes, and a version taken from it
|
||||
* would never equal what `fileVersion()` computes at save time — every save
|
||||
* of that file would be refused as a conflict. Throws ENOENT like readFile.
|
||||
*/
|
||||
export async function readVersioned(filePath: string): Promise<{ text: string; version: string }> {
|
||||
const bytes = await fs.readFile(filePath);
|
||||
return { text: bytes.toString('utf8'), version: versionOf(bytes) };
|
||||
}
|
||||
|
||||
/** Version of the file at `filePath` as it is on disk now. */
|
||||
export async function fileVersion(filePath: string): Promise<string> {
|
||||
try {
|
||||
return versionOf(await fs.readFile(filePath));
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ABSENT_VERSION;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The file changed on disk since the editor loaded it. */
|
||||
export class VersionConflictError extends Error {
|
||||
constructor(readonly currentVersion: string) {
|
||||
super('The file was changed elsewhere since it was loaded.');
|
||||
this.name = 'VersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a VersionConflictError when `filePath` is not at `baseVersion`.
|
||||
* An undefined base version (no `If-Match`) skips the check.
|
||||
*/
|
||||
export async function assertVersion(
|
||||
filePath: string,
|
||||
baseVersion: string | undefined,
|
||||
): Promise<void> {
|
||||
if (baseVersion === undefined) return;
|
||||
const current = await fileVersion(filePath);
|
||||
if (current !== baseVersion) throw new VersionConflictError(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* The base version a PUT sent, or undefined for "no check". Accepts the
|
||||
* quoted ETag form (`"abc"`, `W/"abc"`) as well as a bare token; `*` and an
|
||||
* empty header mean no check.
|
||||
*/
|
||||
export function baseVersionFrom(request: Request): string | undefined {
|
||||
const raw = request.headers.get('if-match')?.trim();
|
||||
if (!raw || raw === '*') return undefined;
|
||||
return raw.replace(/^W\//, '').replace(/^"(.*)"$/, '$1');
|
||||
}
|
||||
|
||||
/** The header form of a version. */
|
||||
export function etag(version: string): string {
|
||||
return `"${version}"`;
|
||||
}
|
||||
|
||||
/** The 409 every content route answers a stale save with. */
|
||||
export function conflictResponse(currentVersion: string): NextResponse {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'This was changed elsewhere since you opened it.',
|
||||
conflict: true,
|
||||
version: currentVersion,
|
||||
},
|
||||
{ status: 409, headers: { ETag: etag(currentVersion) } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run content writes one at a time within this process.
|
||||
*
|
||||
* The version check and the write it guards must not interleave with another
|
||||
* save, or two tabs saving in the same instant would both pass the check. A
|
||||
* single queue for all content files is enough — admin saves are rare and
|
||||
* short — and it also serialises a rename's write-then-delete. Like the rate
|
||||
* limiter it is per process; Folio runs as one.
|
||||
*/
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function serializeContentWrite<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = queue.then(fn, fn);
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export interface DoctorFinding {
|
||||
* diagnostics page can link straight to the album in the page builder.
|
||||
*/
|
||||
albumIds?: string[];
|
||||
/**
|
||||
* The settings section (`/admin/settings/<section>`) that holds the fix, when
|
||||
* it depends on the finding rather than on its id.
|
||||
*/
|
||||
settingsSection?: string;
|
||||
}
|
||||
|
||||
/** The worst level present — what the status badge should show. */
|
||||
@@ -558,6 +563,53 @@ export function checkContact(
|
||||
};
|
||||
}
|
||||
|
||||
/** Where each value checked by `validateSettingValues` is edited. */
|
||||
const SETTING_SECTIONS: Record<string, string> = {
|
||||
url: 'seo',
|
||||
'theme.accent': 'theme',
|
||||
'grid.columns': 'grid',
|
||||
'grid.gap': 'grid',
|
||||
};
|
||||
|
||||
/** Human names for the same fields, for the finding's text. */
|
||||
const SETTING_LABELS: Record<string, string> = {
|
||||
url: 'Site URL',
|
||||
'theme.accent': 'accent colour',
|
||||
'grid.columns': 'grid columns',
|
||||
'grid.gap': 'grid gap',
|
||||
};
|
||||
|
||||
/**
|
||||
* Values in settings.yaml the site does not use as written — an accent that is
|
||||
* not hex, a column count outside 1–6, a site URL that is not a full address.
|
||||
* The resolvers fall back or clamp, so nothing breaks and nothing says so; the
|
||||
* panel now refuses to save them, but a hand-edited file or one saved before
|
||||
* that still holds them. Takes `validateSettingValues(settings.yaml)`.
|
||||
*/
|
||||
export function checkSettingValues(errors: Array<{ field: string }>): DoctorFinding {
|
||||
if (errors.length === 0) {
|
||||
return {
|
||||
id: 'settings-values',
|
||||
level: 'ok',
|
||||
title: 'Settings are used as written',
|
||||
detail: 'Site URL, accent colour and grid values in settings.yaml are all valid.',
|
||||
};
|
||||
}
|
||||
const names = errors.map((e) => SETTING_LABELS[e.field] ?? e.field);
|
||||
return {
|
||||
id: 'settings-values',
|
||||
level: 'warn',
|
||||
title:
|
||||
errors.length === 1
|
||||
? `The ${names[0]} setting is ignored`
|
||||
: `${errors.length} settings are ignored`,
|
||||
detail:
|
||||
`settings.yaml holds a value the site cannot use for: ${names.join(', ')}. ` +
|
||||
'It falls back to the default instead. Open the setting to see what it expects.',
|
||||
settingsSection: SETTING_SECTIONS[errors[0].field],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A site that needs an Impressum almost always needs a privacy policy too
|
||||
* (Art. 13 GDPR). Returns null while there is neither an Impressum nor a
|
||||
@@ -637,3 +689,60 @@ export function checkImmichCalls(results: Array<{ endpoint: string; ok: boolean
|
||||
detail: results.map((r) => r.endpoint).join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageSlugProblem {
|
||||
slug: string;
|
||||
/** One sentence from describeCollision(). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content pages (#722): a `- page:` reference in gallery.yaml whose file does
|
||||
* not exist puts nothing in the menu, and a page whose slug is already taken
|
||||
* is shadowed by the subpage or album — or, for a built-in route, never
|
||||
* reached at all. Returns null while there are no pages and no references.
|
||||
*/
|
||||
export function checkContentPages(p: {
|
||||
/** Slugs referenced from gallery.yaml. */
|
||||
menuRefs: string[];
|
||||
/** Slugs of the files in content/pages/. */
|
||||
pages: string[];
|
||||
collisions: PageSlugProblem[];
|
||||
}): DoctorFinding | null {
|
||||
if (!p.menuRefs.length && !p.pages.length) return null;
|
||||
const existing = new Set(p.pages);
|
||||
const missing = p.menuRefs.filter((slug) => !existing.has(slug));
|
||||
const missingList = missing.map((s) => `"${s}"`).join(', ');
|
||||
|
||||
if (p.collisions.length) {
|
||||
const n = p.collisions.length;
|
||||
return {
|
||||
id: 'content-pages',
|
||||
level: 'error',
|
||||
title: `${n} content ${n === 1 ? 'page has' : 'pages have'} a slug that is already taken`,
|
||||
detail:
|
||||
p.collisions.map((c) => c.reason).join(' ') +
|
||||
' Rename the page in Pages so visitors can reach it.' +
|
||||
(missing.length ? ` The menu also lists missing pages: ${missingList}.` : ''),
|
||||
};
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
return {
|
||||
id: 'content-pages',
|
||||
level: 'warn',
|
||||
title: `The menu lists ${missing.length} missing ${missing.length === 1 ? 'page' : 'pages'}`,
|
||||
detail:
|
||||
`gallery.yaml references ${missingList}, but content/pages/ has no such file. ` +
|
||||
'The menu skips the entry; remove the reference or create the page.',
|
||||
};
|
||||
}
|
||||
|
||||
const n = p.pages.length;
|
||||
return {
|
||||
id: 'content-pages',
|
||||
level: 'ok',
|
||||
title: `${n} content ${n === 1 ? 'page' : 'pages'}, every slug free`,
|
||||
detail: `${p.menuRefs.length} of them in the menu.`,
|
||||
};
|
||||
}
|
||||
|
||||
78
lib/admin/envLocks.ts
Normal file
78
lib/admin/envLocks.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Settings fields that an environment variable overrides (#605).
|
||||
*
|
||||
* Only fields where the environment actually *wins* belong here — an edit in
|
||||
* the panel would save, report success and then do nothing. `SITE_TITLE`,
|
||||
* `SITE_SUBTITLE` and `SITE_URL` are deliberately absent: lib/config/index.ts
|
||||
* resolves them as `settings.X ?? env.X`, so the field takes precedence and
|
||||
* editing it works (see lib/siteUrl.ts).
|
||||
*
|
||||
* Client-safe: no `fs`, no `lib/env` import. The route passes the parsed env
|
||||
* in, and only the variable *names* ever reach the browser.
|
||||
*/
|
||||
|
||||
/** Dotted settings.yaml path → the environment variable that overrides it. */
|
||||
export const ENV_LOCKS = {
|
||||
sitePassword: 'SITE_PASSWORD',
|
||||
'contact.notifyUrl': 'CONTACT_NOTIFY_URL',
|
||||
} as const;
|
||||
|
||||
export type EnvLockedKey = keyof typeof ENV_LOCKS;
|
||||
|
||||
/** Locked path → variable name. Never carries the variable's value. */
|
||||
export type EnvLocks = Partial<Record<EnvLockedKey, string>>;
|
||||
|
||||
/** The locks in effect for this environment: paths whose variable is set. */
|
||||
export function resolveEnvLocks(env: object): EnvLocks {
|
||||
const source = env as Record<string, unknown>;
|
||||
const locks: EnvLocks = {};
|
||||
for (const [path, name] of Object.entries(ENV_LOCKS) as [EnvLockedKey, string][]) {
|
||||
const value = source[name];
|
||||
if (typeof value === 'string' && value.trim() !== '') locks[path] = name;
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
function getPath(obj: unknown, path: string[]): unknown {
|
||||
let cur = obj;
|
||||
for (const key of path) {
|
||||
if (!cur || typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* The incoming settings with every locked path reset to what settings.yaml
|
||||
* already holds. The panel disables those fields, so this only matters for a
|
||||
* hand-crafted request or a stale draft — but a locked field must not change
|
||||
* through a save whose effect the operator could never see.
|
||||
*/
|
||||
export function keepLockedValues<T extends object>(
|
||||
incoming: T,
|
||||
stored: object | null | undefined,
|
||||
locks: EnvLocks,
|
||||
): T {
|
||||
const out = structuredClone(incoming) as Record<string, unknown>;
|
||||
for (const dotted of Object.keys(locks)) {
|
||||
const path = dotted.split('.');
|
||||
const last = path[path.length - 1];
|
||||
const previous = getPath(stored, path);
|
||||
let parent: Record<string, unknown> | null = out;
|
||||
for (const key of path.slice(0, -1)) {
|
||||
const next: unknown = parent[key];
|
||||
if (!next || typeof next !== 'object') {
|
||||
if (previous === undefined) {
|
||||
parent = null;
|
||||
break;
|
||||
}
|
||||
parent[key] = {};
|
||||
}
|
||||
parent = parent[key] as Record<string, unknown>;
|
||||
}
|
||||
if (!parent) continue;
|
||||
if (previous === undefined) delete parent[last];
|
||||
else parent[last] = previous;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import fs from 'fs/promises';
|
||||
import nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
|
||||
import { ParsedFileCache } from './parsedFileCache';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
calculateReadingTime,
|
||||
@@ -184,10 +186,30 @@ export function loadEssayFromFile(filename: string): ParsedJournal | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** What a listing needs from one file — everything but the name-derived fields. */
|
||||
type EntryFacts = Omit<JournalEntrySummary, 'slug' | 'filename'>;
|
||||
|
||||
/**
|
||||
* Parsed listing facts per file, reused while the file is unchanged: every
|
||||
* public page render lists the journal for the header nav (see
|
||||
* parsedFileCache.ts).
|
||||
*/
|
||||
const summaryCache = new ParsedFileCache<EntryFacts>((content) => {
|
||||
const parsed = parseJournalMarkdown(content);
|
||||
const { words, minutes } = calculateReadingTime(content);
|
||||
return {
|
||||
frontmatter: parsed.frontmatter,
|
||||
excerpt: extractExcerpt(parsed),
|
||||
wordCount: words,
|
||||
readingTimeMinutes: minutes,
|
||||
};
|
||||
});
|
||||
|
||||
/** List all journal entries */
|
||||
export async function listJournalEntries(): Promise<JournalEntrySummary[]> {
|
||||
const entries: JournalEntrySummary[] = [];
|
||||
const seenSlugs = new Set<string>();
|
||||
const listed = new Set<string>();
|
||||
|
||||
const scanDir = async (dir: string) => {
|
||||
try {
|
||||
@@ -205,19 +227,10 @@ export async function listJournalEntries(): Promise<JournalEntrySummary[]> {
|
||||
seenSlugs.add(slug);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(path.join(dir, file), 'utf8');
|
||||
const parsed = parseJournalMarkdown(content);
|
||||
const { words, minutes } = calculateReadingTime(content);
|
||||
const excerpt = extractExcerpt(parsed);
|
||||
|
||||
entries.push({
|
||||
slug,
|
||||
filename: file,
|
||||
frontmatter: parsed.frontmatter,
|
||||
excerpt,
|
||||
wordCount: words,
|
||||
readingTimeMinutes: minutes,
|
||||
});
|
||||
const filePath = path.join(dir, file);
|
||||
listed.add(filePath);
|
||||
const summary = await summaryCache.read(filePath);
|
||||
entries.push({ slug, filename: file, ...summary });
|
||||
} catch (err) {
|
||||
console.error(`[Journal] Failed to parse ${file}:`, err);
|
||||
}
|
||||
@@ -229,15 +242,19 @@ export async function listJournalEntries(): Promise<JournalEntrySummary[]> {
|
||||
|
||||
await scanDir(JOURNAL_DIR);
|
||||
await scanDir(LEGACY_ESSAYS_DIR);
|
||||
summaryCache.retain(listed);
|
||||
|
||||
// Sort: descending by date, fallback to title/slug
|
||||
return entries.sort((a, b) => {
|
||||
const dateA = a.frontmatter.date || '';
|
||||
const dateB = b.frontmatter.date || '';
|
||||
if (dateA && dateB) return dateB.localeCompare(dateA);
|
||||
if (dateA && dateB) return dateB.localeCompare(dateA) || a.slug.localeCompare(b.slug);
|
||||
if (dateA) return -1;
|
||||
if (dateB) return 1;
|
||||
return (a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug);
|
||||
return (
|
||||
(a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug) ||
|
||||
a.slug.localeCompare(b.slug)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -246,14 +263,16 @@ export async function readJournalEntry(slug: string): Promise<{
|
||||
slug: string;
|
||||
rawMarkdown: string;
|
||||
parsed: ParsedJournal;
|
||||
/** Version of the file's bytes (#601), for the editor's If-Match. */
|
||||
version: string;
|
||||
} | null> {
|
||||
const filePath = resolveJournalFilePath(slug);
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const rawMarkdown = await fs.readFile(filePath, 'utf8');
|
||||
const { text: rawMarkdown, version } = await readVersioned(filePath);
|
||||
const parsed = parseJournalMarkdown(rawMarkdown);
|
||||
return { slug, rawMarkdown, parsed };
|
||||
return { slug, rawMarkdown, parsed, version };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
@@ -358,3 +377,28 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
|
||||
if (deletedAny) console.log(`[Journal] 🗑️ Deleted ${filename}`);
|
||||
return deletedAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's save (#601): write `slug`, refusing with a VersionConflictError
|
||||
* when the file the editor loaded — `fromSlug`'s, for a rename — is no longer
|
||||
* at `baseVersion`. A rename writes the new file and then deletes the old one.
|
||||
* Check, write and delete run in the content write queue, so another save
|
||||
* cannot land between them. Returns the version of what was written.
|
||||
*/
|
||||
export function saveJournalEntry(
|
||||
slug: string,
|
||||
rawMarkdown: string,
|
||||
options: { baseVersion?: string; fromSlug?: string } = {},
|
||||
): Promise<string> {
|
||||
const fromSlug = options.fromSlug ?? slug;
|
||||
return serializeContentWrite(async () => {
|
||||
if (options.baseVersion !== undefined) {
|
||||
const loaded = resolveJournalFilePath(fromSlug);
|
||||
if (!loaded) throw new Error(`Invalid journal entry slug: "${fromSlug}"`);
|
||||
await assertVersion(loaded, options.baseVersion);
|
||||
}
|
||||
await writeJournalEntry(slug, rawMarkdown);
|
||||
if (fromSlug !== slug) await deleteJournalEntry(fromSlug);
|
||||
return versionOf(rawMarkdown);
|
||||
});
|
||||
}
|
||||
|
||||
59
lib/admin/pageRefs.ts
Normal file
59
lib/admin/pageRefs.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Keep gallery.yaml's `- page: <slug>` references in step with the page files
|
||||
* (#722): a deleted page leaves the menu, a renamed one keeps its place.
|
||||
*
|
||||
* Server only. Writes go through writeGalleryYaml(), so the previous
|
||||
* gallery.yaml is backed up first like every other admin save.
|
||||
*/
|
||||
|
||||
import { readGalleryYamlVersioned, writeGalleryYaml } from './yaml-service';
|
||||
import { VersionConflictError } from './contentVersion';
|
||||
import { invalidateConfigCache, isPageRef } from '../config';
|
||||
|
||||
/**
|
||||
* Pure: the `subpages:` list with every reference to `from` replaced by `to`,
|
||||
* or dropped when `to` is null. Returns the same list when nothing matched.
|
||||
*/
|
||||
export function rewritePageRefs<T>(subpages: T[], from: string, to: string | null): T[] {
|
||||
if (!subpages.some((e) => isPageRef(e) && e.page === from)) return subpages;
|
||||
return subpages.flatMap((entry) => {
|
||||
if (!isPageRef(entry) || entry.page !== from) return [entry];
|
||||
return to === null ? [] : [{ ...entry, page: to } as T];
|
||||
});
|
||||
}
|
||||
|
||||
/** gallery.yaml's version before and after a menu rewrite (#601). */
|
||||
export interface GalleryVersionChange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply rewritePageRefs() to gallery.yaml on disk. Returns the version change
|
||||
* when the file changed, null when it did not.
|
||||
*
|
||||
* The page builder is open while this runs and holds gallery.yaml's version.
|
||||
* With `from` and `to` it can tell "only this rewrite happened since I loaded"
|
||||
* (take the new version) from "someone else saved too" (keep the old one, so
|
||||
* its next save is refused rather than clobbering). The write itself is
|
||||
* checked against the version read here, and re-read on a conflict, so a
|
||||
* builder save racing the rewrite is not overwritten either.
|
||||
*/
|
||||
export async function updatePageRefs(
|
||||
from: string,
|
||||
to: string | null,
|
||||
): Promise<GalleryVersionChange | null> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const { data: gallery, version } = await readGalleryYamlVersioned();
|
||||
if (!gallery || !Array.isArray(gallery.subpages)) return null;
|
||||
const next = rewritePageRefs(gallery.subpages, from, to);
|
||||
if (next === gallery.subpages) return null;
|
||||
try {
|
||||
const written = await writeGalleryYaml({ ...gallery, subpages: next }, version);
|
||||
invalidateConfigCache();
|
||||
return { from: version, to: written };
|
||||
} catch (err) {
|
||||
if (!(err instanceof VersionConflictError) || attempt >= 2) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
lib/admin/pageSlugs.ts
Normal file
27
lib/admin/pageSlugs.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The slugs a content page must not take (#722), gathered from the live
|
||||
* configuration: subpages, standalone albums and journal entries. The fixed
|
||||
* routes are checked by pageSlugCollision() itself.
|
||||
*
|
||||
* Server only. Album slugs need Immich; when it cannot answer, the album list
|
||||
* is empty rather than the whole check failing — a save during an Immich
|
||||
* outage should not be refused for a reason nobody can see.
|
||||
*/
|
||||
|
||||
import { getConfig } from '../config';
|
||||
import { immich } from '../immich';
|
||||
import { listJournalEntries } from './journal-service';
|
||||
import type { SlugTakenBy } from '../pages';
|
||||
|
||||
export async function takenPageSlugs(): Promise<SlugTakenBy> {
|
||||
const config = getConfig();
|
||||
const [albums, journal] = await Promise.all([
|
||||
immich.getStandaloneAlbums().catch(() => []),
|
||||
listJournalEntries().catch(() => []),
|
||||
]);
|
||||
return {
|
||||
subpages: config.subpages.map((sp) => sp.slug),
|
||||
albums: albums.map((a) => a.slug),
|
||||
journal: journal.map((e) => e.slug),
|
||||
};
|
||||
}
|
||||
285
lib/admin/pages-service.ts
Normal file
285
lib/admin/pages-service.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Server-side storage for content pages (#722): `content/pages/<slug>.md`.
|
||||
*
|
||||
* Mirrors journal-service.ts — the same slug rule, the same containment check
|
||||
* on every path, atomic writes, and rotating backups in
|
||||
* `content/pages/.backups/` (ten saves per page; delete and pre-restore
|
||||
* snapshots are never pruned).
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
|
||||
import { isValidSlug, parseJournalMarkdown, type ParsedJournal } from '../journal';
|
||||
import type { PageSummary } from '../pages';
|
||||
import { ParsedFileCache } from './parsedFileCache';
|
||||
|
||||
const MAX_BACKUPS = 10;
|
||||
|
||||
/**
|
||||
* Join a filename onto a directory, or return null if the result would escape
|
||||
* it. isValidSlug() already rejects dots and separators; this is the second
|
||||
* lock, and the barrier the taint analysis in CI can see.
|
||||
*/
|
||||
function containedPath(dir: string, filename: string): string | null {
|
||||
const base = path.resolve(dir);
|
||||
const resolved = path.resolve(base, filename);
|
||||
if (resolved !== base && !resolved.startsWith(base + path.sep)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved per call rather than at import: tests point the service at a
|
||||
* temporary directory by changing the working directory.
|
||||
*/
|
||||
function pagesDir(): string {
|
||||
return path.join(process.cwd(), 'content', 'pages');
|
||||
}
|
||||
function backupDir(): string {
|
||||
return path.join(pagesDir(), '.backups');
|
||||
}
|
||||
|
||||
/** `<slug>.md.<timestamp>[.deleted|.pre-restore].bak` — see journal-service. */
|
||||
const PAGE_BACKUP_NAME = /^([\w-]+)\.md\.[\w-]+\.(?:(deleted|pre-restore)\.)?bak$/;
|
||||
|
||||
export interface PageBackup {
|
||||
filename: string;
|
||||
slug: string;
|
||||
kind: 'save' | 'deleted' | 'pre-restore';
|
||||
}
|
||||
|
||||
/** The file a slug lives in, or null for a slug that is not one. */
|
||||
export function resolvePageFilePath(slug: string): string | null {
|
||||
if (!isValidSlug(slug)) return null;
|
||||
return containedPath(pagesDir(), `${slug}.md`);
|
||||
}
|
||||
|
||||
async function snapshotPage(
|
||||
filePath: string,
|
||||
filename: string,
|
||||
kind?: 'deleted' | 'pre-restore',
|
||||
): Promise<void> {
|
||||
await fs.mkdir(backupDir(), { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const suffix = kind ? `.${kind}` : '';
|
||||
const backupPath = containedPath(backupDir(), `${filename}.${timestamp}${suffix}.bak`);
|
||||
if (!backupPath) {
|
||||
throw new Error(`Refusing to write a backup outside ${backupDir()}: "${filename}"`);
|
||||
}
|
||||
await fs.copyFile(filePath, backupPath);
|
||||
}
|
||||
|
||||
async function prunePageBackups(filename: string): Promise<void> {
|
||||
const backups = (await fs.readdir(backupDir()))
|
||||
.filter((f) => {
|
||||
const match = PAGE_BACKUP_NAME.exec(f);
|
||||
return match !== null && `${match[1]}.md` === filename && match[2] === undefined;
|
||||
})
|
||||
.sort();
|
||||
for (const old of backups.slice(0, Math.max(0, backups.length - MAX_BACKUPS))) {
|
||||
await fs.unlink(path.join(backupDir(), old)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Every page backup on disk, newest first. */
|
||||
export async function listPageBackups(): Promise<PageBackup[]> {
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(backupDir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return files
|
||||
.map((filename) => {
|
||||
const match = PAGE_BACKUP_NAME.exec(filename);
|
||||
if (!match) return null;
|
||||
return { filename, slug: match[1], kind: (match[2] ?? 'save') as PageBackup['kind'] };
|
||||
})
|
||||
.filter((b): b is PageBackup => b !== null)
|
||||
.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* The listed frontmatter per file, reused while the file is unchanged: every
|
||||
* public page render lists the pages for the header nav (see
|
||||
* parsedFileCache.ts).
|
||||
*/
|
||||
const frontmatterCache = new ParsedFileCache<PageSummary['frontmatter']>((raw) => {
|
||||
const { title, description, password, draft } = parseJournalMarkdown(raw).frontmatter;
|
||||
return { title, description, password, draft };
|
||||
});
|
||||
|
||||
/** Every page, sorted by title. Unparseable files are skipped with a log line. */
|
||||
export async function listPages(): Promise<PageSummary[]> {
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(pagesDir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const pages: PageSummary[] = [];
|
||||
const listed = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md')) continue;
|
||||
const slug = file.slice(0, -3);
|
||||
const filePath = resolvePageFilePath(slug);
|
||||
if (!filePath) continue;
|
||||
listed.add(filePath);
|
||||
try {
|
||||
pages.push({ slug, frontmatter: await frontmatterCache.read(filePath) });
|
||||
} catch (err) {
|
||||
console.error(`[Pages] Failed to read ${file}:`, err);
|
||||
}
|
||||
}
|
||||
frontmatterCache.retain(listed);
|
||||
return pages.sort((a, b) =>
|
||||
(a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug),
|
||||
);
|
||||
}
|
||||
|
||||
/** Slugs of every page file, without parsing them. */
|
||||
export function listPageSlugsSync(): string[] {
|
||||
try {
|
||||
return nodeFs
|
||||
.readdirSync(pagesDir())
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => f.slice(0, -3))
|
||||
.filter(isValidSlug);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PageRecord {
|
||||
slug: string;
|
||||
rawMarkdown: string;
|
||||
parsed: ParsedJournal;
|
||||
/** Version of the file's bytes (#601), for the editor's If-Match. */
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** One page, or null when there is no such file. */
|
||||
export async function readPage(slug: string): Promise<PageRecord | null> {
|
||||
const filePath = resolvePageFilePath(slug);
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
const { text: rawMarkdown, version } = await readVersioned(filePath);
|
||||
return { slug, rawMarkdown, parsed: parseJournalMarkdown(rawMarkdown), version };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous read, for the password lookup in lib/auth.ts. */
|
||||
export function readPageSync(slug: string): PageRecord | null {
|
||||
const filePath = resolvePageFilePath(slug);
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
const bytes = nodeFs.readFileSync(filePath);
|
||||
const rawMarkdown = bytes.toString('utf8');
|
||||
return {
|
||||
slug,
|
||||
rawMarkdown,
|
||||
parsed: parseJournalMarkdown(rawMarkdown),
|
||||
version: versionOf(bytes),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a page atomically, snapshotting the previous version first. */
|
||||
export async function writePage(slug: string, rawMarkdown: string): Promise<void> {
|
||||
const filePath = resolvePageFilePath(slug);
|
||||
if (!filePath) throw new Error(`Invalid page slug: "${slug}"`);
|
||||
const filename = `${slug}.md`;
|
||||
await fs.mkdir(pagesDir(), { recursive: true });
|
||||
|
||||
// Only ENOENT means "nothing to back up"; any other failure aborts the save
|
||||
// before it overwrites the live file (the journal's #630).
|
||||
let exists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
exists = false;
|
||||
}
|
||||
if (exists) {
|
||||
await snapshotPage(filePath, filename);
|
||||
await prunePageBackups(filename);
|
||||
}
|
||||
|
||||
await atomicWrite(filePath, rawMarkdown);
|
||||
console.log(`[Pages] ✅ Saved ${filename}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a page, keeping a `.deleted.bak` copy. The snapshot is not optional:
|
||||
* when it cannot be written, the delete is refused.
|
||||
*/
|
||||
export async function deletePage(slug: string): Promise<boolean> {
|
||||
const filePath = resolvePageFilePath(slug);
|
||||
if (!filePath) throw new Error(`Invalid page slug: "${slug}"`);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
await snapshotPage(filePath, `${slug}.md`, 'deleted');
|
||||
await fs.unlink(filePath);
|
||||
console.log(`[Pages] 🗑️ Deleted ${slug}.md`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Restore a page from one of its backups, returning the restored slug. */
|
||||
export async function restorePageBackup(backupFilename: string): Promise<string> {
|
||||
const match = PAGE_BACKUP_NAME.exec(backupFilename);
|
||||
if (!match || path.basename(backupFilename) !== backupFilename || !isValidSlug(match[1])) {
|
||||
throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`);
|
||||
}
|
||||
const slug = match[1];
|
||||
const source = containedPath(backupDir(), backupFilename);
|
||||
const target = resolvePageFilePath(slug);
|
||||
if (!source || !target) {
|
||||
throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`);
|
||||
}
|
||||
const content = await fs.readFile(source, 'utf8');
|
||||
try {
|
||||
await fs.access(target);
|
||||
await snapshotPage(target, `${slug}.md`, 'pre-restore');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
await fs.mkdir(pagesDir(), { recursive: true });
|
||||
await atomicWrite(target, content);
|
||||
console.log(`[Pages] 🔄 Restored ${slug}.md from ${backupFilename}`);
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's save (#601): write `slug`, refusing with a VersionConflictError
|
||||
* when the file the editor loaded — `fromSlug`'s, for a rename — is no longer
|
||||
* at `baseVersion`. A rename writes the new file and then deletes the old one.
|
||||
* Check, write and delete run in the content write queue, so another save
|
||||
* cannot land between them. Returns the version of what was written.
|
||||
*/
|
||||
export function savePage(
|
||||
slug: string,
|
||||
rawMarkdown: string,
|
||||
options: { baseVersion?: string; fromSlug?: string } = {},
|
||||
): Promise<string> {
|
||||
const fromSlug = options.fromSlug ?? slug;
|
||||
return serializeContentWrite(async () => {
|
||||
if (options.baseVersion !== undefined) {
|
||||
const loaded = resolvePageFilePath(fromSlug);
|
||||
if (!loaded) throw new Error(`Invalid page slug: "${fromSlug}"`);
|
||||
await assertVersion(loaded, options.baseVersion);
|
||||
}
|
||||
await writePage(slug, rawMarkdown);
|
||||
if (fromSlug !== slug) await deletePage(fromSlug);
|
||||
return versionOf(rawMarkdown);
|
||||
});
|
||||
}
|
||||
57
lib/admin/parsedFileCache.ts
Normal file
57
lib/admin/parsedFileCache.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Per-file memo for the journal and page listings.
|
||||
*
|
||||
* `listJournalEntries()` and `listPages()` run on every public page render —
|
||||
* the header nav asks whether a journal exists and which pages to link — and
|
||||
* used to read and parse every markdown file each time. The parse is the
|
||||
* expensive part and depends only on the file's bytes, so it is kept here,
|
||||
* keyed on the file's identity and stamp: inode, size and mtime in
|
||||
* nanoseconds.
|
||||
*
|
||||
* An admin save goes through atomicWrite (temp file + rename), which always
|
||||
* produces a new inode, so a save is seen by the very next listing, in this
|
||||
* module instance or any other. An edit in place changes mtime. The stat is
|
||||
* taken before the read, so a write landing between the two at worst stores
|
||||
* the new bytes under the old stamp — which the next stat no longer matches,
|
||||
* so the file is simply read again. Stale bytes under a current stamp cannot
|
||||
* happen.
|
||||
*
|
||||
* Values are handed out as copies: a caller that mutates an entry must not
|
||||
* change what the next request sees.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
|
||||
interface Cached<T> {
|
||||
stamp: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export class ParsedFileCache<T> {
|
||||
private readonly entries = new Map<string, Cached<T>>();
|
||||
|
||||
constructor(private readonly parse: (raw: string) => T) {}
|
||||
|
||||
/**
|
||||
* Parsed contents of `filePath`, from the memo when the file is unchanged.
|
||||
* Throws like `fs.readFile` for a missing or unreadable file, and like
|
||||
* `parse` for one that does not parse — neither outcome is cached.
|
||||
*/
|
||||
async read(filePath: string): Promise<T> {
|
||||
const st = await fs.stat(filePath, { bigint: true });
|
||||
const stamp = `${st.ino}:${st.size}:${st.mtimeNs}`;
|
||||
const hit = this.entries.get(filePath);
|
||||
if (hit && hit.stamp === stamp) return structuredClone(hit.value);
|
||||
|
||||
const value = this.parse(await fs.readFile(filePath, 'utf8'));
|
||||
this.entries.set(filePath, { stamp, value });
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
/** Drop entries for files a listing no longer found, so deletes do not pile up. */
|
||||
retain(filePaths: Set<string>): void {
|
||||
for (const key of this.entries.keys()) {
|
||||
if (!filePaths.has(key)) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
lib/admin/proofing-view.ts
Normal file
96
lib/admin/proofing-view.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* What the admin panel sees of a proofing session: the stored record plus the
|
||||
* album's name and the state, without the Immich lookups repeated per caller.
|
||||
*/
|
||||
|
||||
import { immich, type ImmichAlbum } from '@/lib/immich';
|
||||
import { imageUrl } from '@/lib/urls';
|
||||
import {
|
||||
downloadsRemaining,
|
||||
sessionState,
|
||||
type ProofSession,
|
||||
type ProofState,
|
||||
} from '@/lib/proofSessions';
|
||||
|
||||
export interface AdminProofSession {
|
||||
id: string;
|
||||
clientName: string;
|
||||
albumId: string;
|
||||
/** Null when Immich no longer has the album (or cannot be reached). */
|
||||
albumName: string | null;
|
||||
albumAssetCount: number | null;
|
||||
/** Relative path of the client link. */
|
||||
link: string;
|
||||
createdAt: string;
|
||||
expiresOn: string | null;
|
||||
download: ProofSession['download'];
|
||||
downloadLimit: number | null;
|
||||
downloadsUsed: number;
|
||||
downloadsRemaining: number | null;
|
||||
selected: number;
|
||||
updatedAt: string | null;
|
||||
submittedAt: string | null;
|
||||
state: ProofState;
|
||||
}
|
||||
|
||||
export interface AdminProofPick {
|
||||
assetId: string;
|
||||
position: number;
|
||||
fileName: string;
|
||||
thumbUrl: string;
|
||||
takenAt?: string;
|
||||
}
|
||||
|
||||
/** Load each album once, tolerating ones that are gone or unreachable. */
|
||||
export async function loadAlbums(ids: string[]): Promise<Map<string, ImmichAlbum | null>> {
|
||||
const unique = Array.from(new Set(ids));
|
||||
const albums = await Promise.all(
|
||||
unique.map((id) => immich.getProofingAlbum(id).catch(() => null)),
|
||||
);
|
||||
return new Map(unique.map((id, i) => [id, albums[i]]));
|
||||
}
|
||||
|
||||
export function toAdminSession(
|
||||
session: ProofSession,
|
||||
album: ImmichAlbum | null,
|
||||
): AdminProofSession {
|
||||
return {
|
||||
id: session.id,
|
||||
clientName: session.clientName,
|
||||
albumId: session.albumId,
|
||||
albumName: album?.albumName ?? null,
|
||||
albumAssetCount: album ? album.assets.length : null,
|
||||
link: `/proof/${session.token}`,
|
||||
createdAt: session.createdAt,
|
||||
expiresOn: session.expiresOn ?? null,
|
||||
download: session.download,
|
||||
downloadLimit: session.downloadLimit ?? null,
|
||||
downloadsUsed: session.downloadsUsed,
|
||||
downloadsRemaining: downloadsRemaining(session),
|
||||
selected: session.selection.length,
|
||||
updatedAt: session.updatedAt ?? null,
|
||||
submittedAt: session.submittedAt ?? null,
|
||||
state: sessionState(session),
|
||||
};
|
||||
}
|
||||
|
||||
/** The selected photos in album order, with what the export and preview need. */
|
||||
export function picksFor(session: ProofSession, album: ImmichAlbum | null): AdminProofPick[] {
|
||||
if (!album) return [];
|
||||
const chosen = new Set(session.selection);
|
||||
const media = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO');
|
||||
const picks: AdminProofPick[] = [];
|
||||
media.forEach((asset, index) => {
|
||||
if (!chosen.has(asset.id)) return;
|
||||
picks.push({
|
||||
assetId: asset.id,
|
||||
position: index + 1,
|
||||
fileName: asset.originalFileName,
|
||||
thumbUrl: imageUrl(asset.id, 'thumbnail'),
|
||||
...(asset.localDateTime || asset.fileCreatedAt
|
||||
? { takenAt: asset.localDateTime ?? asset.fileCreatedAt }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
return picks;
|
||||
}
|
||||
@@ -8,35 +8,67 @@ import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import type { GalleryYaml, SettingsYaml } from '../config/schema';
|
||||
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const MAX_BACKUPS = 10; // Keep last 10 backups per file
|
||||
|
||||
/** Read gallery.yaml and return parsed content. */
|
||||
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
|
||||
/** A parsed YAML file and the version (#601) of the bytes it was parsed from. */
|
||||
export interface VersionedYaml<T> {
|
||||
data: T | null;
|
||||
version: string;
|
||||
}
|
||||
|
||||
async function readYamlVersioned<T>(filename: string): Promise<VersionedYaml<T>> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(CONTENT_DIR, 'gallery.yaml'), 'utf8');
|
||||
return yaml.load(raw) as GalleryYaml;
|
||||
const { text, version } = await readVersioned(path.join(CONTENT_DIR, filename));
|
||||
return { data: yaml.load(text) as T, version };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { data: null, version: versionOf(null) };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read gallery.yaml and return parsed content. */
|
||||
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
|
||||
return (await readGalleryYamlVersioned()).data;
|
||||
}
|
||||
|
||||
/** gallery.yaml with its version, for the editor that will save it back. */
|
||||
export function readGalleryYamlVersioned(): Promise<VersionedYaml<GalleryYaml>> {
|
||||
return readYamlVersioned<GalleryYaml>('gallery.yaml');
|
||||
}
|
||||
|
||||
/** Read settings.yaml and return parsed content. */
|
||||
export async function readSettingsYaml(): Promise<SettingsYaml | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(CONTENT_DIR, 'settings.yaml'), 'utf8');
|
||||
return yaml.load(raw) as SettingsYaml;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
return (await readSettingsYamlVersioned()).data;
|
||||
}
|
||||
|
||||
/** Atomically write a YAML file with backup. */
|
||||
async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
/** settings.yaml with its version, for the editor that will save it back. */
|
||||
export function readSettingsYamlVersioned(): Promise<VersionedYaml<SettingsYaml>> {
|
||||
return readYamlVersioned<SettingsYaml>('settings.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write a YAML file with backup, returning the new version.
|
||||
*
|
||||
* With a `baseVersion`, the write is refused with a VersionConflictError when
|
||||
* the file on disk is no longer that version (#601) — checked inside the write
|
||||
* queue, so no other save can slip in between the check and the rename.
|
||||
*/
|
||||
function writeYamlFile(filename: string, data: unknown, baseVersion?: string): Promise<string> {
|
||||
return serializeContentWrite(() => writeYamlFileNow(filename, data, baseVersion));
|
||||
}
|
||||
|
||||
async function writeYamlFileNow(
|
||||
filename: string,
|
||||
data: unknown,
|
||||
baseVersion: string | undefined,
|
||||
): Promise<string> {
|
||||
const filePath = path.join(CONTENT_DIR, filename);
|
||||
await assertVersion(filePath, baseVersion);
|
||||
|
||||
// Ensure content directory exists
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
@@ -85,16 +117,17 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
await atomicWrite(filePath, content);
|
||||
|
||||
console.log(`[Admin] ✅ Saved ${filename}`);
|
||||
return versionOf(content);
|
||||
}
|
||||
|
||||
/** Write gallery.yaml. */
|
||||
export async function writeGalleryYaml(data: GalleryYaml): Promise<void> {
|
||||
await writeYamlFile('gallery.yaml', data);
|
||||
/** Write gallery.yaml; see writeYamlFile for `baseVersion`. Returns the new version. */
|
||||
export function writeGalleryYaml(data: GalleryYaml, baseVersion?: string): Promise<string> {
|
||||
return writeYamlFile('gallery.yaml', data, baseVersion);
|
||||
}
|
||||
|
||||
/** Write settings.yaml. */
|
||||
export async function writeSettingsYaml(data: SettingsYaml): Promise<void> {
|
||||
await writeYamlFile('settings.yaml', data);
|
||||
/** Write settings.yaml; see writeYamlFile for `baseVersion`. Returns the new version. */
|
||||
export function writeSettingsYaml(data: SettingsYaml, baseVersion?: string): Promise<string> {
|
||||
return writeYamlFile('settings.yaml', data, baseVersion);
|
||||
}
|
||||
|
||||
/** List available backups for a file. */
|
||||
|
||||
44
lib/auth.ts
44
lib/auth.ts
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Password gating for subpages, albums, journal entries and the site as a whole.
|
||||
* Password gating for subpages, albums, journal entries, content pages and the
|
||||
* site as a whole.
|
||||
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
|
||||
*
|
||||
* Token = HMAC-SHA256(slug + passwordSecret, authSecret)
|
||||
@@ -11,12 +12,12 @@ import { getConfig, SubpageConfig } from './config';
|
||||
// From the pure schema module, not the config barrel: tests that stub out
|
||||
// '@/lib/config' wholesale would otherwise lose this helper.
|
||||
import { normalizeSlug } from './config/schema';
|
||||
import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
|
||||
import { verifyScrypt, generateScryptHash, isScryptHash, burnScrypt } from './password';
|
||||
|
||||
const TOKEN_EXPIRY_HOURS = 24;
|
||||
|
||||
/** What a password can protect. */
|
||||
export type ProtectedType = 'subpage' | 'album' | 'journal' | 'site';
|
||||
export type ProtectedType = 'subpage' | 'album' | 'journal' | 'page' | 'site';
|
||||
|
||||
/**
|
||||
* Key used for the site-wide gate. There is only ever one, so it is a constant
|
||||
@@ -39,15 +40,24 @@ export const SITE_AUTH_KEY = 'site';
|
||||
*/
|
||||
const SITE_TOKEN_KEY = '__site__';
|
||||
|
||||
/** The key that actually gets signed: only the site gate is remapped. */
|
||||
/**
|
||||
* The key that actually gets signed. The site gate is remapped (see above), and
|
||||
* a content page is prefixed: a page and a subpage or journal entry of the same
|
||||
* slug and password would otherwise sign identical tokens, and a visitor could
|
||||
* copy one cookie's value into the other's name. `page:` cannot be a slug —
|
||||
* isValidSlug() rejects the colon.
|
||||
*/
|
||||
function tokenKey(key: string, type: ProtectedType): string {
|
||||
return type === 'site' ? SITE_TOKEN_KEY : key;
|
||||
if (type === 'site') return SITE_TOKEN_KEY;
|
||||
if (type === 'page') return `page:${key}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<ProtectedType, string> = {
|
||||
subpage: 'Subpage',
|
||||
album: 'Album',
|
||||
journal: 'Journal entry',
|
||||
page: 'Page',
|
||||
site: 'Site',
|
||||
};
|
||||
|
||||
@@ -78,10 +88,12 @@ function authToken(key: string, passwordSecret: string, expiresAt: number): stri
|
||||
import nodeFs from 'fs';
|
||||
import { parseFrontmatter } from './journal';
|
||||
import { resolveJournalFilePath } from './admin/journal-service';
|
||||
import { readPageSync } from './admin/pages-service';
|
||||
|
||||
function cookieName(key: string, type: ProtectedType): string {
|
||||
if (type === 'subpage') return `lb_auth_${key}`;
|
||||
if (type === 'journal') return `lb_auth_journal_${key}`;
|
||||
if (type === 'page') return `lb_auth_page_${key}`;
|
||||
if (type === 'site') return SITE_AUTH_COOKIE;
|
||||
return `lb_auth_album_${key}`;
|
||||
}
|
||||
@@ -107,7 +119,9 @@ export const SITE_AUTH_COOKIE = 'lb_site_auth';
|
||||
*
|
||||
* An album listed on the home page is published there whatever else lists it,
|
||||
* so it stays reachable. Where the only routes are subpages, one of them has
|
||||
* to be open — which mirrors what the visitor could actually have clicked.
|
||||
* to be open — which mirrors what the visitor could actually have clicked. A
|
||||
* subpage taken offline with `enabled: false` is no route at all, so an album
|
||||
* listed only there is not reachable, whatever its password.
|
||||
*/
|
||||
export function isAlbumReachable(
|
||||
albumId: string,
|
||||
@@ -121,7 +135,7 @@ export function isAlbumReachable(
|
||||
const routes = config.subpages.filter(
|
||||
(sp) => sp.enabled !== false && sp.albumIds.includes(albumId),
|
||||
);
|
||||
if (routes.length === 0) return true;
|
||||
if (routes.length === 0) return false;
|
||||
|
||||
return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage'));
|
||||
}
|
||||
@@ -175,6 +189,9 @@ function findPassword(key: string, type: ProtectedType): string | undefined {
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
if (type === 'page') {
|
||||
return readPageSync(key)?.parsed.frontmatter.password || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -217,8 +234,13 @@ export async function authenticate(
|
||||
type: ProtectedType = 'subpage',
|
||||
secure = true,
|
||||
): Promise<string | null> {
|
||||
// Every path below runs exactly one scrypt, so the reply time does not tell a
|
||||
// prober which keys exist, which carry a password, or how it is stored.
|
||||
const storedPassword = findPassword(key, type);
|
||||
if (!storedPassword) return null;
|
||||
if (!storedPassword) {
|
||||
await burnScrypt(password);
|
||||
return null;
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
|
||||
@@ -229,6 +251,7 @@ export async function authenticate(
|
||||
` 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`,
|
||||
);
|
||||
await burnScrypt(password);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -247,7 +270,10 @@ export async function authenticate(
|
||||
.digest();
|
||||
isValid = crypto.timingSafeEqual(attemptHash, storedHash);
|
||||
|
||||
if (isValid) {
|
||||
if (!isValid) {
|
||||
await burnScrypt(password);
|
||||
} else {
|
||||
// The success path pays its scrypt here, for the recommended hash.
|
||||
const recommendedHash = await generateScryptHash(storedPassword);
|
||||
console.warn(
|
||||
`\n⚠️ SECURITY WARNING: ${TYPE_LABELS[type]} "${key}" is using a plaintext password${type === 'site' ? ' in settings.yaml' : ' in gallery.yaml'}.\n` +
|
||||
|
||||
22
lib/cache.ts
22
lib/cache.ts
@@ -17,8 +17,28 @@ interface CacheEntry<T> {
|
||||
|
||||
const DEFAULT_STALE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
/**
|
||||
* The entries live on globalThis, not in this module.
|
||||
*
|
||||
* Next bundles each route separately, so every route handler gets its own
|
||||
* instance of this module. A module-level Map meant the admin panel's
|
||||
* "Clear cache", a save's invalidation and the Immich webhook each cleared a
|
||||
* cache no page ever read from, and the diagnostics page reported the size
|
||||
* of its own, always empty, copy ("0 items"). The process is shared, so one
|
||||
* store on globalThis is the cache every instance reads and clears. The key is
|
||||
* versioned so a dev server's hot reload never hands a new build entries in
|
||||
* an older shape.
|
||||
*/
|
||||
const STORE_KEY = Symbol.for('immich-folio.immich-cache.v2');
|
||||
|
||||
function sharedStore(): Map<string, CacheEntry<unknown>> {
|
||||
const holder = globalThis as unknown as Record<symbol, Map<string, CacheEntry<unknown>>>;
|
||||
holder[STORE_KEY] ??= new Map();
|
||||
return holder[STORE_KEY];
|
||||
}
|
||||
|
||||
class MemoryCache {
|
||||
private store = new Map<string, CacheEntry<unknown>>();
|
||||
private store = sharedStore();
|
||||
private maxEntries = 200;
|
||||
|
||||
get size(): number {
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { env } from '../env';
|
||||
import { resolveAuthSecret } from '../secret';
|
||||
import { resolveSiteUrl } from '../siteUrl';
|
||||
import { getInstallCredentials } from '../install';
|
||||
import { getInstallCredentials, installFilePath } from '../install';
|
||||
import { loadYaml, clearYamlCache, validateUuid } from './parser';
|
||||
import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
|
||||
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
|
||||
@@ -30,14 +30,23 @@ import {
|
||||
CONTACT_RETENTION_DEFAULT,
|
||||
CONTACT_RETENTION_MAX,
|
||||
isHttpUrl,
|
||||
isPageRef,
|
||||
type GallerySubpageYaml,
|
||||
} from './schema';
|
||||
import { isValidSlug } from '../journal';
|
||||
import { getDictionary, resolveLocale } from '../i18n';
|
||||
import type { NavEntry } from '../pages';
|
||||
|
||||
export * from './schema';
|
||||
export * from './theme';
|
||||
|
||||
/** The last derived config and the inputs it was derived from; see getConfig(). */
|
||||
let derived: { inputs: string; config: AppConfig } | null = null;
|
||||
|
||||
/** Invalidate the cached YAML so the next getConfig() call re-parses the files. */
|
||||
export function invalidateConfigCache(): void {
|
||||
clearYamlCache();
|
||||
derived = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,6 +159,20 @@ export function resolveProofing(
|
||||
return subpage?.proofing ?? globalEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient of the proofing dialog's "email to photographer" (#736):
|
||||
* `proofing.email` when set, else the footer contact email. The first
|
||||
* non-empty value, trimmed — an address saved as only whitespace is not
|
||||
* configured. Undefined means neither is set, and the dialog hides the button
|
||||
* rather than opening a compose with an empty To:.
|
||||
*/
|
||||
export function resolveProofingEmail(
|
||||
proofingEmail?: string,
|
||||
footerEmail?: string,
|
||||
): string | undefined {
|
||||
return (proofingEmail ?? '').trim() || (footerEmail ?? '').trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL: keep only nav links that are safe to render as header <a>
|
||||
* tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are
|
||||
@@ -244,6 +267,7 @@ export interface GalleryDerivation {
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
nav: NavEntry[];
|
||||
albumOverrides: Record<string, string>;
|
||||
albumDescriptions: Record<string, string>;
|
||||
albumPasswords: Record<string, string>;
|
||||
@@ -408,9 +432,35 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
|
||||
|
||||
let subpages: SubpageConfig[] = [];
|
||||
/** Menu order, subpages and `- page:` references interleaved (#722). */
|
||||
const nav: NavEntry[] = [];
|
||||
|
||||
if (Array.isArray(gallery.subpages)) {
|
||||
subpages = gallery.subpages.map((sp) => {
|
||||
const pageRefs = new Set<string>();
|
||||
const subpageEntries: GallerySubpageYaml[] = [];
|
||||
for (const entry of gallery.subpages) {
|
||||
if (isPageRef(entry)) {
|
||||
const slug = entry.page.trim();
|
||||
// Thrown like every other structure error: the admin PUT turns it into
|
||||
// a 400, and a hand-edited file degrades to the setup screen.
|
||||
if (!isValidSlug(slug)) {
|
||||
throw new Error(
|
||||
`gallery.yaml subpages: "page: ${entry.page}" is not a valid page slug ` +
|
||||
`(letters, digits, "-" and "_" only).`,
|
||||
);
|
||||
}
|
||||
if (pageRefs.has(slug)) {
|
||||
throw new Error(`gallery.yaml subpages: page "${slug}" is listed more than once.`);
|
||||
}
|
||||
pageRefs.add(slug);
|
||||
nav.push({ type: 'page', slug });
|
||||
} else {
|
||||
subpageEntries.push(entry);
|
||||
// Placeholder, filled in with the derived slug below.
|
||||
nav.push({ type: 'subpage', slug: '' });
|
||||
}
|
||||
}
|
||||
subpages = subpageEntries.map((sp) => {
|
||||
if (!sp.name) {
|
||||
throw new Error(`Subpage "(unnamed)" must have a name`);
|
||||
}
|
||||
@@ -467,6 +517,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
...buildSubpageGrids(sp.grid, sp.coverGrid),
|
||||
};
|
||||
});
|
||||
let next = 0;
|
||||
for (const entry of nav) {
|
||||
if (entry.type === 'subpage') entry.slug = subpages[next++].slug;
|
||||
}
|
||||
} else if (gallery.subpages) {
|
||||
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -500,6 +554,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(gallery.subpages)) {
|
||||
for (const sp of subpages) nav.push({ type: 'subpage', slug: sp.slug });
|
||||
}
|
||||
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
const standaloneAlbums = standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id));
|
||||
@@ -550,6 +608,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums,
|
||||
subpages,
|
||||
nav,
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
albumPasswords,
|
||||
@@ -572,11 +631,54 @@ function aboutContentExists(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything deriveConfig() reads, as one string: the path and mtime of each
|
||||
* file (or `-` when it is absent) and the parsed environment.
|
||||
*/
|
||||
function configInputs(): string {
|
||||
const content = path.join(process.cwd(), 'content');
|
||||
const files = [
|
||||
installFilePath(),
|
||||
path.join(content, 'gallery.yaml'),
|
||||
path.join(content, 'settings.yaml'),
|
||||
path.join(content, 'about.md'),
|
||||
];
|
||||
// throwIfNoEntry: a missing file is the normal case for install.json and
|
||||
// about.md, and building an ENOENT error costs more than the stat.
|
||||
// turbopackIgnore: `file` is not statically resolvable, so without the
|
||||
// comment Turbopack traces the whole project into .next/standalone. Only
|
||||
// the mtime is read here; the files themselves are loaded elsewhere.
|
||||
const stamps = files.map(
|
||||
(file) =>
|
||||
`${file}@${fs.statSync(/* turbopackIgnore: true */ file, { throwIfNoEntry: false })?.mtimeMs ?? '-'}`,
|
||||
);
|
||||
return `${stamps.join('|')}|${JSON.stringify(env)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The site configuration.
|
||||
*
|
||||
* Admin saves can land in a different worker or process than page rendering,
|
||||
* so this cannot be a per-process singleton: freshness comes from the files'
|
||||
* mtimes, re-checked on every call (a statSync each). What is cached is the
|
||||
* derivation. A render and every image request call this many times over
|
||||
* (`immich.config` is a getter), and rebuilding the whole config from the
|
||||
* YAML each time cost more than the stats.
|
||||
*
|
||||
* The inputs are the ones loadYaml() and readInstallFile() already key their
|
||||
* own caches on, so this is never staler than they are. Each caller still
|
||||
* gets its own copy, exactly as before: nothing can mutate the cached one.
|
||||
* A config that throws is not cached, so it throws again on the next call.
|
||||
*/
|
||||
export function getConfig(): AppConfig {
|
||||
// No in-memory config cache: admin saves can land in a different
|
||||
// worker/process than page rendering, so a per-worker cache goes stale
|
||||
// until restart. Freshness comes from the mtime-checked YAML cache in
|
||||
// loadYaml() — a statSync per file, negligible next to Immich API calls.
|
||||
const inputs = configInputs();
|
||||
if (derived?.inputs !== inputs) {
|
||||
derived = { inputs, config: deriveConfig() };
|
||||
}
|
||||
return structuredClone(derived.config);
|
||||
}
|
||||
|
||||
function deriveConfig(): AppConfig {
|
||||
const { apiUrl, apiKey } = getInstallCredentials();
|
||||
const authSecret = resolveAuthSecret();
|
||||
|
||||
@@ -604,6 +706,7 @@ export function getConfig(): AppConfig {
|
||||
albums: [],
|
||||
standaloneAlbums: [],
|
||||
subpages: [],
|
||||
nav: [],
|
||||
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
||||
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
||||
lang: 'en',
|
||||
@@ -665,6 +768,7 @@ export function getConfig(): AppConfig {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums,
|
||||
subpages,
|
||||
nav,
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
albumPasswords,
|
||||
@@ -677,7 +781,10 @@ export function getConfig(): AppConfig {
|
||||
albumLocationPrecision,
|
||||
} = deriveGallery(gallery);
|
||||
|
||||
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
|
||||
// Last-resort title in the site's own language rather than a hardcoded
|
||||
// English "Gallery" (#696).
|
||||
const defaultTitle = env.SITE_TITLE || getDictionary(resolveLocale(settings.lang)).common.gallery;
|
||||
const siteSeoTitle = settings.seo?.title || settings.title || defaultTitle;
|
||||
|
||||
return {
|
||||
immich: { apiUrl: immichApiUrl, apiKey },
|
||||
@@ -685,7 +792,8 @@ export function getConfig(): AppConfig {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums,
|
||||
subpages,
|
||||
siteTitle: settings.title ?? env.SITE_TITLE,
|
||||
nav,
|
||||
siteTitle: settings.title ?? defaultTitle,
|
||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||
lang: settings.lang ?? 'en',
|
||||
// Env wins, so a deployment can rotate the site password without touching
|
||||
@@ -746,6 +854,9 @@ export function getConfig(): AppConfig {
|
||||
proofing: {
|
||||
enabled: settings.proofing?.enabled !== false,
|
||||
allowMailto: settings.proofing?.allowMailto !== false,
|
||||
// `proofing.email`, else the footer contact email (#736); absent hides
|
||||
// the dialog's email button.
|
||||
email: resolveProofingEmail(settings.proofing?.email, settings.footer?.email),
|
||||
},
|
||||
protection: settings.protection,
|
||||
watermark: settings.watermark,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AlbumSortMode } from '../albumSort';
|
||||
import type { LocationPrecision } from '../mapPrecision';
|
||||
import type { NavEntry } from '../pages';
|
||||
|
||||
export interface SubpageSectionConfig {
|
||||
title: string;
|
||||
@@ -217,6 +218,11 @@ export interface AppConfig {
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
/**
|
||||
* The header menu in gallery.yaml order: every subpage (hidden and disabled
|
||||
* ones included — the menu filters) and every `- page:` reference (#722).
|
||||
*/
|
||||
nav: NavEntry[];
|
||||
siteTitle: string;
|
||||
siteSubtitle: string;
|
||||
lang: string;
|
||||
@@ -264,6 +270,13 @@ export interface AppConfig {
|
||||
proofing: {
|
||||
enabled: boolean;
|
||||
allowMailto: boolean;
|
||||
/**
|
||||
* Effective "email to photographer" recipient: `proofing.email` from
|
||||
* settings when set, otherwise the footer contact email. Undefined means
|
||||
* neither is configured, and the proofing dialog hides the email action
|
||||
* rather than opening a compose with an empty To: (#736).
|
||||
*/
|
||||
email?: string;
|
||||
};
|
||||
aboutEnabled: boolean;
|
||||
albumOverrides: Record<string, string>;
|
||||
@@ -352,43 +365,63 @@ export interface AlbumEntryObject {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content page placed in the menu (#722): `- page: <slug>` among the
|
||||
* subpages. The page itself lives in content/pages/<slug>.md.
|
||||
*/
|
||||
export interface PageRefYaml {
|
||||
page: string;
|
||||
}
|
||||
|
||||
/** Whether a raw `subpages:` list entry is a page reference rather than a subpage. */
|
||||
export function isPageRef(entry: unknown): entry is PageRefYaml {
|
||||
return (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
'page' in entry &&
|
||||
typeof (entry as { page: unknown }).page === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
subpages?:
|
||||
| Record<string, string[] | Array<string | Record<string, string>>>
|
||||
| Array<{
|
||||
name: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
}>;
|
||||
password?: string;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
hidden?: boolean;
|
||||
/** Map precision inherited by every album here (#469). */
|
||||
location?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
/** Album covers only; falls back to `grid` when unset (#523). */
|
||||
coverGrid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
}>;
|
||||
| Array<GallerySubpageYaml | PageRefYaml>;
|
||||
}
|
||||
|
||||
export interface GallerySubpageYaml {
|
||||
name: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
}>;
|
||||
password?: string;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
hidden?: boolean;
|
||||
/** Map precision inherited by every album here (#469). */
|
||||
location?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
/** Album covers only; falls back to `grid` when unset (#523). */
|
||||
coverGrid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsYaml {
|
||||
@@ -417,6 +450,8 @@ export interface SettingsYaml {
|
||||
proofing?: {
|
||||
enabled?: boolean;
|
||||
allowMailto?: boolean;
|
||||
/** Overrides the footer contact email as the proofing recipient. */
|
||||
email?: string;
|
||||
};
|
||||
theme?:
|
||||
| string
|
||||
@@ -644,3 +679,44 @@ export function normalizeSlug(slug: string): string {
|
||||
}
|
||||
return decoded.normalize('NFC');
|
||||
}
|
||||
|
||||
/**
|
||||
* The albums the public site shows: the standalone ones and those of every
|
||||
* subpage that is not offline. `albums` is the allowlist for loading an album
|
||||
* and still holds the albums of a subpage taken offline with `enabled: false`
|
||||
* (its configuration is kept), so it does not answer "is this published".
|
||||
* Anything that lists albums or serves their photos outside a page route asks
|
||||
* this instead.
|
||||
*/
|
||||
export function onlineAlbumIds(config: {
|
||||
standaloneAlbums?: readonly string[];
|
||||
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean }>;
|
||||
}): Set<string> {
|
||||
return new Set([
|
||||
...(config.standaloneAlbums ?? []),
|
||||
...config.subpages.filter((sp) => sp.enabled !== false).flatMap((sp) => sp.albumIds),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A subpage that appears in listings: online and not `hidden`. */
|
||||
export function isListedSubpage(sp: { enabled?: boolean; hidden?: boolean }): boolean {
|
||||
return sp.enabled !== false && sp.hidden !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The albums a public listing may name: the standalone ones and those of
|
||||
* every subpage that is online and not `hidden`. Narrower than
|
||||
* {@link onlineAlbumIds} — a hidden subpage is reachable by direct link only,
|
||||
* so its photos are still served (publishedAssets asks `onlineAlbumIds`), but
|
||||
* an overview such as the map must not advertise them. An album that a listed
|
||||
* subpage or the standalone list also carries stays.
|
||||
*/
|
||||
export function listedAlbumIds(config: {
|
||||
standaloneAlbums?: readonly string[];
|
||||
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean; hidden?: boolean }>;
|
||||
}): Set<string> {
|
||||
return new Set([
|
||||
...(config.standaloneAlbums ?? []),
|
||||
...config.subpages.filter(isListedSubpage).flatMap((sp) => sp.albumIds),
|
||||
]);
|
||||
}
|
||||
|
||||
71
lib/config/settingValues.ts
Normal file
71
lib/config/settingValues.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Values in settings.yaml that the site would silently ignore (QA A-14).
|
||||
*
|
||||
* `validateSettings` (settingsSchema.ts) checks types and structure and
|
||||
* deliberately leaves values to the resolvers, which clamp or fall back so
|
||||
* hand-edited YAML never takes the site down. For the fields the panel offers
|
||||
* an input for, that meant a save of `accent: rot` or nine columns in a 1–6
|
||||
* field was reported as "Saved" while the site showed something else.
|
||||
*
|
||||
* This checks exactly those fields — the site URL, the accent, the grid
|
||||
* columns and gap — with the resolvers' own rules and bounds, so it can never
|
||||
* be stricter than what renders. Kept apart from settingsSchema.ts so the
|
||||
* settings form can run it as you type without bundling zod; the save route
|
||||
* runs it before writing, and the doctor over settings.yaml as it is.
|
||||
*/
|
||||
|
||||
import { normaliseSiteUrl } from '../siteUrl';
|
||||
import { isAccentHex } from './theme';
|
||||
import { PHOTO_GRID_COLUMNS_MAX, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_GAP_MAX } from './schema';
|
||||
import type { SettingsFieldError } from './settingsSchema';
|
||||
|
||||
function inRange(value: unknown, min: number, max: number): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
/** One error per offending field; an empty string counts as unset, as in the resolvers. */
|
||||
export function validateSettingValues(input: unknown): SettingsFieldError[] {
|
||||
if (!input || typeof input !== 'object') return [];
|
||||
const s = input as {
|
||||
url?: unknown;
|
||||
theme?: unknown;
|
||||
grid?: { columns?: unknown; gap?: unknown } | null;
|
||||
};
|
||||
const errors: SettingsFieldError[] = [];
|
||||
|
||||
// resolveSiteUrl: an unusable value falls back to SITE_URL, or to nothing,
|
||||
// and the sitemap, feed and structured data go out without the address.
|
||||
if (typeof s.url === 'string' && s.url.trim() !== '' && !normaliseSiteUrl(s.url)) {
|
||||
errors.push({
|
||||
field: 'url',
|
||||
message: 'Must be a full http(s) address, e.g. https://photos.example.com.',
|
||||
});
|
||||
}
|
||||
|
||||
// resolveTheme: anything but hex falls back to the preset's accent.
|
||||
const accent =
|
||||
s.theme && typeof s.theme === 'object' ? (s.theme as { accent?: unknown }).accent : undefined;
|
||||
if (typeof accent === 'string' && accent.trim() !== '' && !isAccentHex(accent)) {
|
||||
errors.push({ field: 'theme.accent', message: 'Must be a hex colour such as #e60012.' });
|
||||
}
|
||||
|
||||
// getConfig clamps both into these bounds (lib/config/index.ts).
|
||||
const grid = s.grid && typeof s.grid === 'object' ? s.grid : null;
|
||||
if (
|
||||
grid?.columns != null &&
|
||||
!inRange(grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX)
|
||||
) {
|
||||
errors.push({
|
||||
field: 'grid.columns',
|
||||
message: `Must be a whole number from ${PHOTO_GRID_COLUMNS_MIN} to ${PHOTO_GRID_COLUMNS_MAX}.`,
|
||||
});
|
||||
}
|
||||
if (grid?.gap != null && !inRange(grid.gap, 0, PHOTO_GRID_GAP_MAX)) {
|
||||
errors.push({
|
||||
field: 'grid.gap',
|
||||
message: `Must be a whole number from 0 to ${PHOTO_GRID_GAP_MAX}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -29,6 +29,10 @@
|
||||
* to the preset's own value for anything that is not the right type, which
|
||||
* covers hand-edited YAML as well as a save from this panel.
|
||||
*
|
||||
* **Values the panel offers an input for** are the exception, checked with the
|
||||
* resolvers' own rules in `settingValues.ts`: the site URL, the accent and the
|
||||
* grid columns and gap, which a save used to accept and the site then ignored.
|
||||
*
|
||||
* **Unknown keys pass through.** There is no `schemaVersion` and no migration
|
||||
* code, so configurations older and newer than this build are both in the wild.
|
||||
* A key this version has never heard of is not evidence of a mistake, and
|
||||
@@ -84,7 +88,7 @@ export const settingsSchema = z.looseObject({
|
||||
scrollToTop: bool,
|
||||
analytics: bool,
|
||||
|
||||
proofing: z.looseObject({ enabled: bool, allowMailto: bool }).optional(),
|
||||
proofing: z.looseObject({ enabled: bool, allowMailto: bool, email: str }).optional(),
|
||||
|
||||
// Checked separately in validateSettings — see themeObjectSchema.
|
||||
theme: z.unknown().optional(),
|
||||
|
||||
@@ -130,6 +130,11 @@ function resolveRadius(raw: unknown, fallback: number): number {
|
||||
*/
|
||||
const ACCENT_HEX = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
/** Whether `resolveTheme` keeps an accent rather than falling back to the preset's. */
|
||||
export function isAccentHex(value: string): boolean {
|
||||
return ACCENT_HEX.test(value.trim());
|
||||
}
|
||||
|
||||
/** Trim, case-fold and widen to `#rrggbb`; refuse anything that is not hex. */
|
||||
function resolveAccent(raw: unknown, fallback: string): string {
|
||||
if (typeof raw !== 'string') return fallback;
|
||||
|
||||
@@ -152,7 +152,8 @@ function parseEnv(): Env {
|
||||
return {
|
||||
IMMICH_API_URL: apiUrl,
|
||||
IMMICH_API_KEY: apiKey as string,
|
||||
SITE_TITLE: process.env.SITE_TITLE || 'Gallery',
|
||||
// No English default here: lib/config falls back to the locale's word.
|
||||
SITE_TITLE: process.env.SITE_TITLE || '',
|
||||
SITE_SUBTITLE: process.env.SITE_SUBTITLE || '',
|
||||
CACHE_TTL: Math.max(0, cacheTtl),
|
||||
STALE_MAX_AGE: Math.max(0, staleMaxAge),
|
||||
|
||||
16
lib/escapeHtml.ts
Normal file
16
lib/escapeHtml.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Escapes special HTML characters for Leaflet's string-based popups and icons.
|
||||
*
|
||||
* Lives outside components/LeafletMap.tsx so a caller can build marker HTML
|
||||
* without importing the map — EssayView loads the map on demand, and a static
|
||||
* import of this helper from there would pull it (and Leaflet's stylesheet)
|
||||
* back into every essay page.
|
||||
*/
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -18,8 +18,11 @@ export type EssayFileResult =
|
||||
| { status: 'open'; parsed: ParsedJournal }
|
||||
/** No such entry, or a draft seen by a non-admin — both render as absent. */
|
||||
| { status: 'missing' }
|
||||
/** The entry has a password this visitor has not entered. */
|
||||
| { status: 'locked'; title: string };
|
||||
/**
|
||||
* The entry has a password this visitor has not entered. No title: the
|
||||
* journal index hides a locked entry by name, so the gate must not show it.
|
||||
*/
|
||||
| { status: 'locked' };
|
||||
|
||||
export function resolveEssayFile(
|
||||
slug: string,
|
||||
@@ -31,7 +34,7 @@ export function resolveEssayFile(
|
||||
const { frontmatter } = parsed;
|
||||
if (frontmatter.draft && !visitor.isAdmin) return { status: 'missing' };
|
||||
if (frontmatter.password && !isAuthenticated(slug, visitor.getCookie, 'journal')) {
|
||||
return { status: 'locked', title: frontmatter.title || slug };
|
||||
return { status: 'locked' };
|
||||
}
|
||||
return { status: 'open', parsed };
|
||||
}
|
||||
|
||||
55
lib/focusTrap.ts
Normal file
55
lib/focusTrap.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Focus helpers for modal UI (lightbox) and the mobile nav panel (#696).
|
||||
* Client-only: every function works on live DOM nodes.
|
||||
*/
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
/** Keyboard-reachable elements inside `container`, in DOM order. */
|
||||
export function focusableIn(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) =>
|
||||
!el.closest('[hidden], [inert], [aria-hidden="true"]') &&
|
||||
el.tabIndex >= 0 &&
|
||||
// A `display: none` control (the shortcut panel on touch devices) cannot
|
||||
// take focus; wrapping onto it would strand the visitor.
|
||||
(typeof el.checkVisibility !== 'function' || el.checkVisibility()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps Tab and Shift+Tab inside `container`: from the last element Tab wraps
|
||||
* to the first, and the other way round. Focus that has escaped (or never
|
||||
* entered) is pulled back in. Returns true when it moved focus itself.
|
||||
*/
|
||||
export function trapTabKey(container: HTMLElement, e: KeyboardEvent): boolean {
|
||||
if (e.key !== 'Tab') return false;
|
||||
const items = focusableIn(container);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const inside = active !== null && container.contains(active);
|
||||
|
||||
if (e.shiftKey && (!inside || active === first)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
return true;
|
||||
}
|
||||
if (!e.shiftKey && (!inside || active === last)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export const de: Dictionary = {
|
||||
map: 'Karte',
|
||||
journal: 'Journal',
|
||||
skipToContent: 'Zum Inhalt springen',
|
||||
mainNavAria: 'Hauptnavigation',
|
||||
heroNavAria: 'Bereiche',
|
||||
openMenu: 'Menü öffnen',
|
||||
closeMenu: 'Menü schließen',
|
||||
},
|
||||
@@ -44,12 +46,15 @@ export const de: Dictionary = {
|
||||
nextEntryAria: (title: string) => `Nächster Beitrag: ${title}`,
|
||||
entryNavAria: 'Journal-Navigation',
|
||||
loadingGallery: 'Galerie wird geladen',
|
||||
gallery: 'Galerie',
|
||||
loadingPhotos: 'Fotos werden geladen',
|
||||
downloadAlbum: 'Album herunterladen',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Eintreten',
|
||||
pauseSlideshow: 'Diashow anhalten',
|
||||
playSlideshow: 'Diashow abspielen',
|
||||
},
|
||||
|
||||
error: {
|
||||
@@ -58,8 +63,6 @@ export const de: Dictionary = {
|
||||
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}`,
|
||||
},
|
||||
@@ -71,6 +74,7 @@ export const de: Dictionary = {
|
||||
immichUnavailable:
|
||||
'Die Fotobibliothek ist gerade nicht erreichbar. Bitte gleich noch einmal versuchen.',
|
||||
back: 'Zurück zur Galerie',
|
||||
limitReached: 'Das Download-Limit für diesen Link ist erreicht.',
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -82,11 +86,18 @@ export const de: Dictionary = {
|
||||
password: {
|
||||
subtitle: 'Diese Galerie ist passwortgeschützt.',
|
||||
siteSubtitle: 'Diese Website ist passwortgeschützt.',
|
||||
pageSubtitle: 'Diese Seite ist passwortgeschützt.',
|
||||
journalSubtitle: 'Dieser Journal-Eintrag ist passwortgeschützt.',
|
||||
protectedPage: 'Geschützte Seite',
|
||||
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.',
|
||||
tooManyAttempts: (seconds: number | null) =>
|
||||
seconds
|
||||
? `Zu viele Versuche – bitte in ${seconds} ${seconds === 1 ? 'Sekunde' : 'Sekunden'} erneut versuchen.`
|
||||
: 'Zu viele Versuche – bitte später erneut versuchen.',
|
||||
},
|
||||
|
||||
about: {
|
||||
@@ -104,6 +115,9 @@ export const de: Dictionary = {
|
||||
loading: 'Karte wird geladen…',
|
||||
loadFailed: (status: number) => `Kartendaten konnten nicht geladen werden (${status})`,
|
||||
initFailed: 'Karte konnte nicht initialisiert werden',
|
||||
zoomIn: 'Vergrößern',
|
||||
zoomOut: 'Verkleinern',
|
||||
leafletTitle: 'Eine JavaScript-Bibliothek für interaktive Karten',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
@@ -130,6 +144,7 @@ export const de: Dictionary = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Bildansicht',
|
||||
position: (n: number, total: number) => `Foto ${n} von ${total}`,
|
||||
openPhoto: (n: number) => `Foto ${n} ansehen`,
|
||||
close: 'Schließen',
|
||||
closeTitle: 'Schließen (Esc)',
|
||||
@@ -164,10 +179,10 @@ export const de: Dictionary = {
|
||||
downloadTitle: 'Originaldatei herunterladen (d)',
|
||||
downloadShort: 'Original',
|
||||
shortcutDownload: 'Original herunterladen',
|
||||
shortcutSlideshow: 'Diaschau',
|
||||
shortcutSlideshowRunning: (seconds: number) => `Diaschau — alle ${seconds}s`,
|
||||
slideshowStopped: 'Diaschau angehalten',
|
||||
slideshowRunning: (seconds: number) => `Diaschau läuft, wechselt alle ${seconds} Sekunden`,
|
||||
shortcutSlideshow: 'Diashow',
|
||||
shortcutSlideshowRunning: (seconds: number) => `Diashow — alle ${seconds}s`,
|
||||
slideshowStopped: 'Diashow angehalten',
|
||||
slideshowRunning: (seconds: number) => `Diashow läuft, wechselt alle ${seconds} Sekunden`,
|
||||
shortcutCopyLink: 'Link zum Foto kopieren',
|
||||
shortcutList: 'Diese Liste',
|
||||
shortcutClose: 'Ansicht schließen',
|
||||
@@ -181,6 +196,7 @@ export const de: Dictionary = {
|
||||
saved: 'Gemerkt',
|
||||
favorite: 'Merken',
|
||||
showAll: 'Alle anzeigen',
|
||||
filterEmpty: 'Keine Fotos ausgewählt.',
|
||||
selected: (n: number) => `❤️ ${n} ausgewählt`,
|
||||
shareExport: 'Teilen & Export',
|
||||
modalTitle: (n: number) => `❤️ Auswahl (${n})`,
|
||||
@@ -200,11 +216,36 @@ export const de: Dictionary = {
|
||||
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)`,
|
||||
mailSubject: (n: number) => `Fotoauswahl (${plural(n, 'Foto', 'Fotos')})`,
|
||||
mailBody: (list: string, url: string) =>
|
||||
`Hallo,\n\nhier ist meine Fotoauswahl:\n\n${list}\n\nLink zur Auswahl: ${url}\n\nViele Grüße,`,
|
||||
},
|
||||
|
||||
proofSession: {
|
||||
greeting: (name: string) => `Auswahl für ${name}`,
|
||||
intro:
|
||||
'Markiere die gewünschten Fotos mit dem Herz. Deine Auswahl wird laufend gespeichert — du kannst mit diesem Link jederzeit zurückkommen.',
|
||||
saving: 'Wird gespeichert…',
|
||||
saved: 'Auswahl gespeichert',
|
||||
saveFailed:
|
||||
'Deine Auswahl konnte nicht gespeichert werden. Prüfe die Verbindung und versuche es erneut.',
|
||||
review: 'Prüfen & absenden',
|
||||
modalTitle: (n: number) => `Deine Auswahl (${n})`,
|
||||
empty: 'Noch keine Fotos ausgewählt.',
|
||||
submit: 'Auswahl absenden',
|
||||
submitting: 'Wird gesendet…',
|
||||
confirmSubmit: (n: number) =>
|
||||
`${plural(n, 'Foto', 'Fotos')} absenden? Danach lässt sich die Auswahl nicht mehr ändern.`,
|
||||
submitted: 'Danke — deine Auswahl wurde abgeschickt.',
|
||||
locked: 'Diese Auswahl wurde abgeschickt und kann nicht mehr geändert werden.',
|
||||
validUntil: (date: string) => `Dieser Link ist gültig bis ${date}.`,
|
||||
expiredTitle: 'Dieser Link ist abgelaufen',
|
||||
expiredText: 'Bitte frag deinen Fotografen nach einem neuen Link.',
|
||||
downloadSelection: 'Auswahl herunterladen (.zip)',
|
||||
downloadAll: 'Alle Fotos herunterladen (.zip)',
|
||||
downloadsLeft: (n: number) => `Noch ${plural(n, 'Download', 'Downloads')}`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Impressum',
|
||||
title: 'Impressum',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const en = {
|
||||
map: 'Map',
|
||||
journal: 'Journal',
|
||||
skipToContent: 'Skip to content',
|
||||
mainNavAria: 'Main',
|
||||
heroNavAria: 'Sections',
|
||||
openMenu: 'Open menu',
|
||||
closeMenu: 'Close menu',
|
||||
},
|
||||
@@ -45,12 +47,15 @@ export const en = {
|
||||
nextEntryAria: (title: string) => `Next entry: ${title}`,
|
||||
entryNavAria: 'Journal entry navigation',
|
||||
loadingGallery: 'Loading gallery',
|
||||
gallery: 'Gallery',
|
||||
loadingPhotos: 'Loading photos',
|
||||
downloadAlbum: 'Download album',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Enter',
|
||||
pauseSlideshow: 'Pause slideshow',
|
||||
playSlideshow: 'Play slideshow',
|
||||
},
|
||||
|
||||
error: {
|
||||
@@ -59,8 +64,6 @@ export const en = {
|
||||
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}`,
|
||||
},
|
||||
@@ -72,6 +75,7 @@ export const en = {
|
||||
rateLimited: 'Too many download requests. Please wait a moment and try again.',
|
||||
immichUnavailable: 'The photo library is unavailable right now. Please try again shortly.',
|
||||
back: 'Back to the gallery',
|
||||
limitReached: 'The download limit for this link has been reached.',
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -83,11 +87,18 @@ export const en = {
|
||||
password: {
|
||||
subtitle: 'This gallery is password-protected.',
|
||||
siteSubtitle: 'This site is password-protected.',
|
||||
pageSubtitle: 'This page is password-protected.',
|
||||
journalSubtitle: 'This journal entry is password-protected.',
|
||||
protectedPage: 'Protected page',
|
||||
placeholder: 'Enter password',
|
||||
submit: 'Enter',
|
||||
verifying: 'Verifying…',
|
||||
incorrect: 'Incorrect password. Please try again.',
|
||||
failed: 'Unable to verify password. Please try again later.',
|
||||
tooManyAttempts: (seconds: number | null) =>
|
||||
seconds
|
||||
? `Too many attempts. Please try again in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}.`
|
||||
: 'Too many attempts. Please try again later.',
|
||||
},
|
||||
|
||||
about: {
|
||||
@@ -105,6 +116,10 @@ export const en = {
|
||||
loading: 'Loading map…',
|
||||
loadFailed: (status: number) => `Failed to load map data (${status})`,
|
||||
initFailed: 'Failed to initialize map',
|
||||
zoomIn: 'Zoom in',
|
||||
zoomOut: 'Zoom out',
|
||||
// The title on the Leaflet credit in the map's corner.
|
||||
leafletTitle: 'A JavaScript library for interactive maps',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
@@ -131,6 +146,7 @@ export const en = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Image viewer',
|
||||
position: (n: number, total: number) => `Photo ${n} of ${total}`,
|
||||
/** A grid tile, which opens the viewer on that photo. */
|
||||
openPhoto: (n: number) => `View photo ${n}`,
|
||||
close: 'Close',
|
||||
@@ -183,6 +199,8 @@ export const en = {
|
||||
saved: 'Saved',
|
||||
favorite: 'Favorite',
|
||||
showAll: 'Show All',
|
||||
/** Shown in place of the grid when the filter is on and nothing is selected. */
|
||||
filterEmpty: 'No photos selected.',
|
||||
selected: (n: number) => `❤️ ${n} Selected`,
|
||||
shareExport: 'Share & Export',
|
||||
modalTitle: (n: number) => `❤️ Selection (${n})`,
|
||||
@@ -203,11 +221,36 @@ export const en = {
|
||||
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)`,
|
||||
mailSubject: (n: number) => `Photo Selection (${plural(n, 'item', 'items')})`,
|
||||
mailBody: (list: string, url: string) =>
|
||||
`Hello,\n\nHere is my photo selection:\n\n${list}\n\nShare Link: ${url}\n\nBest regards,`,
|
||||
},
|
||||
|
||||
/** Client proofing links (/proof/<token>). */
|
||||
proofSession: {
|
||||
greeting: (name: string) => `Selection for ${name}`,
|
||||
intro:
|
||||
'Tap the heart on the photos you want. Your selection is saved as you go — you can come back with this link at any time.',
|
||||
saving: 'Saving…',
|
||||
saved: 'Selection saved',
|
||||
saveFailed: 'Your selection could not be saved. Check your connection and try again.',
|
||||
review: 'Review & submit',
|
||||
modalTitle: (n: number) => `Your selection (${n})`,
|
||||
empty: 'No photos selected yet.',
|
||||
submit: 'Submit selection',
|
||||
submitting: 'Submitting…',
|
||||
confirmSubmit: (n: number) =>
|
||||
`Submit ${plural(n, 'photo', 'photos')}? The selection cannot be changed afterwards.`,
|
||||
submitted: 'Thank you — your selection has been submitted.',
|
||||
locked: 'This selection has been submitted and can no longer be changed.',
|
||||
validUntil: (date: string) => `This link is valid until ${date}.`,
|
||||
expiredTitle: 'This link has expired',
|
||||
expiredText: 'Please ask your photographer for a new link.',
|
||||
downloadSelection: 'Download selection (.zip)',
|
||||
downloadAll: 'Download all photos (.zip)',
|
||||
downloadsLeft: (n: number) => `${plural(n, 'download', 'downloads')} left`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Legal Notice',
|
||||
title: 'Legal Notice',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const es: Dictionary = {
|
||||
map: 'Mapa',
|
||||
journal: 'Diario',
|
||||
skipToContent: 'Saltar al contenido',
|
||||
mainNavAria: 'Navegación principal',
|
||||
heroNavAria: 'Secciones',
|
||||
openMenu: 'Abrir menú',
|
||||
closeMenu: 'Cerrar menú',
|
||||
},
|
||||
@@ -44,12 +46,15 @@ export const es: Dictionary = {
|
||||
nextEntryAria: (title: string) => `Entrada siguiente: ${title}`,
|
||||
entryNavAria: 'Navegación entre entradas del diario',
|
||||
loadingGallery: 'Cargando la galería',
|
||||
gallery: 'Galería',
|
||||
loadingPhotos: 'Cargando fotos',
|
||||
downloadAlbum: 'Descargar álbum',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Entrar',
|
||||
pauseSlideshow: 'Pausar la presentación',
|
||||
playSlideshow: 'Reproducir la presentación',
|
||||
},
|
||||
|
||||
error: {
|
||||
@@ -58,8 +63,6 @@ export const es: Dictionary = {
|
||||
errorTitle: 'Algo ha salido mal',
|
||||
errorText:
|
||||
'No se ha podido cargar esta página. Suele ser algo temporal: inténtalo de nuevo en un momento.',
|
||||
siteErrorText:
|
||||
'No se ha podido cargar este sitio. Suele ser algo temporal: inténtalo de nuevo en un momento.',
|
||||
tryAgain: 'Reintentar',
|
||||
reference: (digest: string) => `Referencia: ${digest}`,
|
||||
},
|
||||
@@ -71,6 +74,7 @@ export const es: Dictionary = {
|
||||
immichUnavailable:
|
||||
'La fototeca no está disponible en este momento. Vuelve a intentarlo en breve.',
|
||||
back: 'Volver a la galería',
|
||||
limitReached: 'Se ha alcanzado el límite de descargas de este enlace.',
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -82,11 +86,18 @@ export const es: Dictionary = {
|
||||
password: {
|
||||
subtitle: 'Esta galería está protegida con contraseña.',
|
||||
siteSubtitle: 'Este sitio está protegido con contraseña.',
|
||||
pageSubtitle: 'Esta página está protegida con contraseña.',
|
||||
journalSubtitle: 'Esta entrada del diario está protegida con contraseña.',
|
||||
protectedPage: 'Página protegida',
|
||||
placeholder: 'Introduce la contraseña',
|
||||
submit: 'Entrar',
|
||||
verifying: 'Verificando…',
|
||||
incorrect: 'Contraseña incorrecta. Inténtalo de nuevo.',
|
||||
failed: 'No se ha podido verificar la contraseña. Inténtalo más tarde.',
|
||||
tooManyAttempts: (seconds: number | null) =>
|
||||
seconds
|
||||
? `Demasiados intentos. Inténtalo de nuevo en ${seconds} ${seconds === 1 ? 'segundo' : 'segundos'}.`
|
||||
: 'Demasiados intentos. Inténtalo más tarde.',
|
||||
},
|
||||
|
||||
about: {
|
||||
@@ -104,6 +115,9 @@ export const es: Dictionary = {
|
||||
loading: 'Cargando el mapa…',
|
||||
loadFailed: (status: number) => `No se pudieron cargar los datos del mapa (${status})`,
|
||||
initFailed: 'No se pudo inicializar el mapa',
|
||||
zoomIn: 'Acercar',
|
||||
zoomOut: 'Alejar',
|
||||
leafletTitle: 'Una biblioteca de JavaScript para mapas interactivos',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
@@ -130,6 +144,7 @@ export const es: Dictionary = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Visor de imágenes',
|
||||
position: (n: number, total: number) => `Foto ${n} de ${total}`,
|
||||
openPhoto: (n: number) => `Ver foto ${n}`,
|
||||
close: 'Cerrar',
|
||||
closeTitle: 'Cerrar (Esc)',
|
||||
@@ -181,6 +196,7 @@ export const es: Dictionary = {
|
||||
saved: 'Guardado',
|
||||
favorite: 'Favorito',
|
||||
showAll: 'Mostrar todo',
|
||||
filterEmpty: 'No hay fotos seleccionadas.',
|
||||
selected: (n: number) => `❤️ ${n} ${n === 1 ? 'seleccionada' : 'seleccionadas'}`,
|
||||
shareExport: 'Compartir y exportar',
|
||||
modalTitle: (n: number) => `❤️ Selección (${n})`,
|
||||
@@ -205,6 +221,30 @@ export const es: Dictionary = {
|
||||
`Hola:\n\nEsta es mi selección de fotos:\n\n${list}\n\nEnlace: ${url}\n\nUn saludo,`,
|
||||
},
|
||||
|
||||
proofSession: {
|
||||
greeting: (name: string) => `Selección para ${name}`,
|
||||
intro:
|
||||
'Toca el corazón en las fotos que quieras. Tu selección se guarda sobre la marcha: puedes volver con este enlace cuando quieras.',
|
||||
saving: 'Guardando…',
|
||||
saved: 'Selección guardada',
|
||||
saveFailed: 'No se ha podido guardar tu selección. Revisa la conexión e inténtalo de nuevo.',
|
||||
review: 'Revisar y enviar',
|
||||
modalTitle: (n: number) => `Tu selección (${n})`,
|
||||
empty: 'Todavía no has seleccionado ninguna foto.',
|
||||
submit: 'Enviar selección',
|
||||
submitting: 'Enviando…',
|
||||
confirmSubmit: (n: number) =>
|
||||
`¿Enviar ${plural(n, 'foto', 'fotos')}? Después no podrás cambiar la selección.`,
|
||||
submitted: 'Gracias: tu selección se ha enviado.',
|
||||
locked: 'Esta selección ya se ha enviado y no se puede modificar.',
|
||||
validUntil: (date: string) => `Este enlace es válido hasta el ${date}.`,
|
||||
expiredTitle: 'Este enlace ha caducado',
|
||||
expiredText: 'Pide un enlace nuevo a tu fotógrafo.',
|
||||
downloadSelection: 'Descargar selección (.zip)',
|
||||
downloadAll: 'Descargar todas las fotos (.zip)',
|
||||
downloadsLeft: (n: number) => `${plural(n, 'descarga restante', 'descargas restantes')}`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Aviso legal',
|
||||
title: 'Aviso legal',
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
import type { Dictionary } from '../index';
|
||||
|
||||
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
|
||||
// CLDR: French puts 0 in the singular ("0 photo", "0 jour"), unlike English.
|
||||
const isOne = (n: number) => n === 0 || n === 1;
|
||||
const plural = (n: number, one: string, other: string) => `${n} ${isOne(n) ? one : other}`;
|
||||
|
||||
export const fr: Dictionary = {
|
||||
dateLocale: 'fr-FR',
|
||||
@@ -19,6 +21,8 @@ export const fr: Dictionary = {
|
||||
map: 'Carte',
|
||||
journal: 'Journal',
|
||||
skipToContent: 'Aller au contenu',
|
||||
mainNavAria: 'Navigation principale',
|
||||
heroNavAria: 'Rubriques',
|
||||
openMenu: 'Ouvrir le menu',
|
||||
closeMenu: 'Fermer le menu',
|
||||
},
|
||||
@@ -44,12 +48,15 @@ export const fr: Dictionary = {
|
||||
nextEntryAria: (title: string) => `Article suivant : ${title}`,
|
||||
entryNavAria: 'Navigation entre les articles du journal',
|
||||
loadingGallery: 'Chargement de la galerie',
|
||||
gallery: 'Galerie',
|
||||
loadingPhotos: 'Chargement des photos',
|
||||
downloadAlbum: 'Télécharger l’album',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Entrer',
|
||||
pauseSlideshow: 'Mettre le diaporama en pause',
|
||||
playSlideshow: 'Lancer le diaporama',
|
||||
},
|
||||
|
||||
error: {
|
||||
@@ -58,8 +65,6 @@ export const fr: Dictionary = {
|
||||
errorTitle: 'Une erreur est survenue',
|
||||
errorText:
|
||||
'Cette page n’a pas pu être chargée. C’est généralement temporaire — réessayez dans un instant.',
|
||||
siteErrorText:
|
||||
'Ce site n’a pas pu être chargé. C’est généralement temporaire — réessayez dans un instant.',
|
||||
tryAgain: 'Réessayer',
|
||||
reference: (digest: string) => `Référence : ${digest}`,
|
||||
},
|
||||
@@ -71,6 +76,7 @@ export const fr: Dictionary = {
|
||||
immichUnavailable:
|
||||
'La photothèque est momentanément indisponible. Veuillez réessayer dans un instant.',
|
||||
back: 'Retour à la galerie',
|
||||
limitReached: 'La limite de téléchargements de ce lien est atteinte.',
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -82,11 +88,18 @@ export const fr: Dictionary = {
|
||||
password: {
|
||||
subtitle: 'Cette galerie est protégée par un mot de passe.',
|
||||
siteSubtitle: 'Ce site est protégé par un mot de passe.',
|
||||
pageSubtitle: 'Cette page est protégée par un mot de passe.',
|
||||
journalSubtitle: 'Cet article du journal est protégé par un mot de passe.',
|
||||
protectedPage: 'Page protégée',
|
||||
placeholder: 'Saisissez le mot de passe',
|
||||
submit: 'Entrer',
|
||||
verifying: 'Vérification…',
|
||||
incorrect: 'Mot de passe incorrect. Veuillez réessayer.',
|
||||
failed: 'Impossible de vérifier le mot de passe. Veuillez réessayer plus tard.',
|
||||
tooManyAttempts: (seconds: number | null) =>
|
||||
seconds
|
||||
? `Trop de tentatives. Veuillez réessayer dans ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}.`
|
||||
: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
},
|
||||
|
||||
about: {
|
||||
@@ -104,6 +117,9 @@ export const fr: Dictionary = {
|
||||
loading: 'Chargement de la carte…',
|
||||
loadFailed: (status: number) => `Échec du chargement des données de la carte (${status})`,
|
||||
initFailed: 'Impossible d’initialiser la carte',
|
||||
zoomIn: 'Zoom avant',
|
||||
zoomOut: 'Zoom arrière',
|
||||
leafletTitle: 'Une bibliothèque JavaScript pour cartes interactives',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
@@ -130,6 +146,7 @@ export const fr: Dictionary = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Visionneuse d’images',
|
||||
position: (n: number, total: number) => `Photo ${n} sur ${total}`,
|
||||
openPhoto: (n: number) => `Afficher la photo ${n}`,
|
||||
close: 'Fermer',
|
||||
closeTitle: 'Fermer (Échap)',
|
||||
@@ -182,7 +199,8 @@ export const fr: Dictionary = {
|
||||
saved: 'Enregistré',
|
||||
favorite: 'Favori',
|
||||
showAll: 'Tout afficher',
|
||||
selected: (n: number) => `❤️ ${n} ${n === 1 ? 'sélectionnée' : 'sélectionnées'}`,
|
||||
filterEmpty: 'Aucune photo sélectionnée.',
|
||||
selected: (n: number) => `❤️ ${n} ${isOne(n) ? 'sélectionnée' : 'sélectionnées'}`,
|
||||
shareExport: 'Partager & exporter',
|
||||
modalTitle: (n: number) => `❤️ Sélection (${n})`,
|
||||
closeModal: 'Fermer la fenêtre',
|
||||
@@ -206,6 +224,32 @@ export const fr: Dictionary = {
|
||||
`Bonjour,\n\nVoici ma sélection de photos :\n\n${list}\n\nLien de partage : ${url}\n\nCordialement,`,
|
||||
},
|
||||
|
||||
proofSession: {
|
||||
greeting: (name: string) => `Sélection pour ${name}`,
|
||||
intro:
|
||||
'Touchez le cœur sur les photos que vous souhaitez. Votre sélection est enregistrée au fur et à mesure — vous pouvez revenir avec ce lien à tout moment.',
|
||||
saving: 'Enregistrement…',
|
||||
saved: 'Sélection enregistrée',
|
||||
saveFailed:
|
||||
'Votre sélection n’a pas pu être enregistrée. Vérifiez votre connexion et réessayez.',
|
||||
review: 'Vérifier & envoyer',
|
||||
modalTitle: (n: number) => `Votre sélection (${n})`,
|
||||
empty: 'Aucune photo sélectionnée pour le moment.',
|
||||
submit: 'Envoyer la sélection',
|
||||
submitting: 'Envoi…',
|
||||
confirmSubmit: (n: number) =>
|
||||
`Envoyer ${plural(n, 'photo', 'photos')} ? La sélection ne pourra plus être modifiée ensuite.`,
|
||||
submitted: 'Merci — votre sélection a été envoyée.',
|
||||
locked: 'Cette sélection a été envoyée et ne peut plus être modifiée.',
|
||||
validUntil: (date: string) => `Ce lien est valable jusqu’au ${date}.`,
|
||||
expiredTitle: 'Ce lien a expiré',
|
||||
expiredText: 'Demandez un nouveau lien à votre photographe.',
|
||||
downloadSelection: 'Télécharger la sélection (.zip)',
|
||||
downloadAll: 'Télécharger toutes les photos (.zip)',
|
||||
downloadsLeft: (n: number) =>
|
||||
`${plural(n, 'téléchargement restant', 'téléchargements restants')}`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Mentions légales',
|
||||
title: 'Mentions légales',
|
||||
@@ -236,7 +280,7 @@ export const fr: Dictionary = {
|
||||
tooMany: 'Trop de messages en peu de temps. Réessayez dans une minute.',
|
||||
failed: 'Le message n’a pas pu être envoyé. Réessayez plus tard.',
|
||||
privacy: (days: number) =>
|
||||
`Votre message est conservé sur le serveur de ce site, sert uniquement à vous répondre et est supprimé après ${days} ${days === 1 ? 'jour' : 'jours'}.`,
|
||||
`Votre message est conservé sur le serveur de ce site, sert uniquement à vous répondre et est supprimé après ${days} ${isOne(days) ? 'jour' : 'jours'}.`,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
|
||||
@@ -19,6 +19,8 @@ export const it: Dictionary = {
|
||||
map: 'Mappa',
|
||||
journal: 'Diario',
|
||||
skipToContent: 'Vai al contenuto',
|
||||
mainNavAria: 'Navigazione principale',
|
||||
heroNavAria: 'Sezioni',
|
||||
openMenu: 'Apri menu',
|
||||
closeMenu: 'Chiudi menu',
|
||||
},
|
||||
@@ -45,12 +47,15 @@ export const it: Dictionary = {
|
||||
nextEntryAria: (title: string) => `Articolo successivo: ${title}`,
|
||||
entryNavAria: 'Navigazione tra gli articoli del diario',
|
||||
loadingGallery: 'Caricamento della galleria',
|
||||
gallery: 'Galleria',
|
||||
loadingPhotos: 'Caricamento delle foto',
|
||||
downloadAlbum: 'Scarica album',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Entra',
|
||||
pauseSlideshow: 'Metti in pausa la presentazione',
|
||||
playSlideshow: 'Avvia la presentazione',
|
||||
},
|
||||
|
||||
error: {
|
||||
@@ -59,8 +64,6 @@ export const it: Dictionary = {
|
||||
errorTitle: 'Qualcosa è andato storto',
|
||||
errorText:
|
||||
'Non è stato possibile caricare questa pagina. Di solito è temporaneo: riprova tra un momento.',
|
||||
siteErrorText:
|
||||
'Non è stato possibile caricare questo sito. Di solito è temporaneo: riprova tra un momento.',
|
||||
tryAgain: 'Riprova',
|
||||
reference: (digest: string) => `Riferimento: ${digest}`,
|
||||
},
|
||||
@@ -71,6 +74,7 @@ export const it: Dictionary = {
|
||||
rateLimited: 'Troppe richieste di download. Attendi un momento e riprova.',
|
||||
immichUnavailable: 'La libreria fotografica non è al momento disponibile. Riprova a breve.',
|
||||
back: 'Torna alla galleria',
|
||||
limitReached: 'È stato raggiunto il limite di download per questo link.',
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -82,11 +86,18 @@ export const it: Dictionary = {
|
||||
password: {
|
||||
subtitle: 'Questa galleria è protetta da password.',
|
||||
siteSubtitle: 'Questo sito è protetto da password.',
|
||||
pageSubtitle: 'Questa pagina è protetta da password.',
|
||||
journalSubtitle: 'Questo articolo del diario è protetto da password.',
|
||||
protectedPage: 'Pagina protetta',
|
||||
placeholder: 'Inserisci la password',
|
||||
submit: 'Entra',
|
||||
verifying: 'Verifica in corso…',
|
||||
incorrect: 'Password errata. Riprova.',
|
||||
failed: 'Impossibile verificare la password. Riprova più tardi.',
|
||||
tooManyAttempts: (seconds: number | null) =>
|
||||
seconds
|
||||
? `Troppi tentativi. Riprova tra ${seconds} ${seconds === 1 ? 'secondo' : 'secondi'}.`
|
||||
: 'Troppi tentativi. Riprova più tardi.',
|
||||
},
|
||||
|
||||
about: {
|
||||
@@ -104,6 +115,9 @@ export const it: Dictionary = {
|
||||
loading: 'Caricamento della mappa…',
|
||||
loadFailed: (status: number) => `Impossibile caricare i dati della mappa (${status})`,
|
||||
initFailed: 'Impossibile inizializzare la mappa',
|
||||
zoomIn: 'Ingrandisci',
|
||||
zoomOut: 'Riduci',
|
||||
leafletTitle: 'Una libreria JavaScript per mappe interattive',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
@@ -130,6 +144,7 @@ export const it: Dictionary = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Visualizzatore di immagini',
|
||||
position: (n: number, total: number) => `Foto ${n} di ${total}`,
|
||||
openPhoto: (n: number) => `Apri la foto ${n}`,
|
||||
close: 'Chiudi',
|
||||
closeTitle: 'Chiudi (Esc)',
|
||||
@@ -181,6 +196,7 @@ export const it: Dictionary = {
|
||||
saved: 'Salvato',
|
||||
favorite: 'Preferito',
|
||||
showAll: 'Mostra tutto',
|
||||
filterEmpty: 'Nessuna foto selezionata.',
|
||||
selected: (n: number) => `❤️ ${n} ${n === 1 ? 'selezionata' : 'selezionate'}`,
|
||||
shareExport: 'Condividi ed esporta',
|
||||
modalTitle: (n: number) => `❤️ Selezione (${n})`,
|
||||
@@ -205,6 +221,30 @@ export const it: Dictionary = {
|
||||
`Ciao,\n\necco la mia selezione di foto:\n\n${list}\n\nLink di condivisione: ${url}\n\nCordiali saluti,`,
|
||||
},
|
||||
|
||||
proofSession: {
|
||||
greeting: (name: string) => `Selezione per ${name}`,
|
||||
intro:
|
||||
'Tocca il cuore sulle foto che desideri. La tua selezione viene salvata man mano: puoi tornare con questo link in qualsiasi momento.',
|
||||
saving: 'Salvataggio…',
|
||||
saved: 'Selezione salvata',
|
||||
saveFailed: 'Non è stato possibile salvare la selezione. Controlla la connessione e riprova.',
|
||||
review: 'Controlla e invia',
|
||||
modalTitle: (n: number) => `La tua selezione (${n})`,
|
||||
empty: 'Nessuna foto selezionata.',
|
||||
submit: 'Invia selezione',
|
||||
submitting: 'Invio in corso…',
|
||||
confirmSubmit: (n: number) =>
|
||||
`Inviare ${n} foto? Dopo non sarà più possibile modificare la selezione.`,
|
||||
submitted: 'Grazie, la tua selezione è stata inviata.',
|
||||
locked: 'Questa selezione è stata inviata e non può più essere modificata.',
|
||||
validUntil: (date: string) => `Questo link è valido fino al ${date}.`,
|
||||
expiredTitle: 'Questo link è scaduto',
|
||||
expiredText: 'Chiedi un nuovo link al tuo fotografo.',
|
||||
downloadSelection: 'Scarica selezione (.zip)',
|
||||
downloadAll: 'Scarica tutte le foto (.zip)',
|
||||
downloadsLeft: (n: number) => `${plural(n, 'download rimasto', 'download rimasti')}`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Note legali',
|
||||
title: 'Note legali',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const nl: Dictionary = {
|
||||
map: 'Kaart',
|
||||
journal: 'Journal',
|
||||
skipToContent: 'Naar de inhoud',
|
||||
mainNavAria: 'Hoofdnavigatie',
|
||||
heroNavAria: 'Onderdelen',
|
||||
openMenu: 'Menu openen',
|
||||
closeMenu: 'Menu sluiten',
|
||||
},
|
||||
@@ -44,12 +46,15 @@ export const nl: Dictionary = {
|
||||
nextEntryAria: (title: string) => `Volgend verhaal: ${title}`,
|
||||
entryNavAria: 'Navigatie tussen journalverhalen',
|
||||
loadingGallery: 'Galerij laden',
|
||||
gallery: 'Galerie',
|
||||
loadingPhotos: 'Foto’s laden',
|
||||
downloadAlbum: 'Album downloaden',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Binnenkomen',
|
||||
pauseSlideshow: 'Diavoorstelling pauzeren',
|
||||
playSlideshow: 'Diavoorstelling afspelen',
|
||||
},
|
||||
|
||||
error: {
|
||||
@@ -58,8 +63,6 @@ export const nl: Dictionary = {
|
||||
errorTitle: 'Er is iets misgegaan',
|
||||
errorText:
|
||||
'Deze pagina kon niet worden geladen. Meestal is dat tijdelijk — probeer het zo nog eens.',
|
||||
siteErrorText:
|
||||
'Deze site kon niet worden geladen. Meestal is dat tijdelijk — probeer het zo nog eens.',
|
||||
tryAgain: 'Opnieuw proberen',
|
||||
reference: (digest: string) => `Referentie: ${digest}`,
|
||||
},
|
||||
@@ -71,6 +74,7 @@ export const nl: Dictionary = {
|
||||
immichUnavailable:
|
||||
'De fotobibliotheek is op dit moment niet bereikbaar. Probeer het zo opnieuw.',
|
||||
back: 'Terug naar de galerij',
|
||||
limitReached: 'Het downloadlimiet voor deze link is bereikt.',
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -82,11 +86,18 @@ export const nl: Dictionary = {
|
||||
password: {
|
||||
subtitle: 'Deze galerij is met een wachtwoord beveiligd.',
|
||||
siteSubtitle: 'Deze site is met een wachtwoord beveiligd.',
|
||||
pageSubtitle: 'Deze pagina is met een wachtwoord beveiligd.',
|
||||
journalSubtitle: 'Dit journalverhaal is met een wachtwoord beveiligd.',
|
||||
protectedPage: 'Beveiligde pagina',
|
||||
placeholder: 'Voer het wachtwoord in',
|
||||
submit: 'Binnenkomen',
|
||||
verifying: 'Controleren…',
|
||||
incorrect: 'Onjuist wachtwoord. Probeer het opnieuw.',
|
||||
failed: 'Het wachtwoord kon niet worden gecontroleerd. Probeer het later opnieuw.',
|
||||
tooManyAttempts: (seconds: number | null) =>
|
||||
seconds
|
||||
? `Te veel pogingen. Probeer het over ${seconds} ${seconds === 1 ? 'seconde' : 'seconden'} opnieuw.`
|
||||
: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||
},
|
||||
|
||||
about: {
|
||||
@@ -104,6 +115,9 @@ export const nl: Dictionary = {
|
||||
loading: 'Kaart laden…',
|
||||
loadFailed: (status: number) => `Kaartgegevens konden niet worden geladen (${status})`,
|
||||
initFailed: 'De kaart kon niet worden gestart',
|
||||
zoomIn: 'Inzoomen',
|
||||
zoomOut: 'Uitzoomen',
|
||||
leafletTitle: 'Een JavaScript-bibliotheek voor interactieve kaarten',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
@@ -130,6 +144,7 @@ export const nl: Dictionary = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Fotoviewer',
|
||||
position: (n: number, total: number) => `Foto ${n} van ${total}`,
|
||||
openPhoto: (n: number) => `Foto ${n} bekijken`,
|
||||
close: 'Sluiten',
|
||||
closeTitle: 'Sluiten (Esc)',
|
||||
@@ -182,6 +197,7 @@ export const nl: Dictionary = {
|
||||
saved: 'Opgeslagen',
|
||||
favorite: 'Favoriet',
|
||||
showAll: 'Alles tonen',
|
||||
filterEmpty: "Geen foto's geselecteerd.",
|
||||
selected: (n: number) => `❤️ ${n} geselecteerd`,
|
||||
shareExport: 'Delen & exporteren',
|
||||
modalTitle: (n: number) => `❤️ Selectie (${n})`,
|
||||
@@ -206,6 +222,31 @@ export const nl: Dictionary = {
|
||||
`Hallo,\n\nHier is mijn fotoselectie:\n\n${list}\n\nDeellink: ${url}\n\nMet vriendelijke groet,`,
|
||||
},
|
||||
|
||||
proofSession: {
|
||||
greeting: (name: string) => `Selectie voor ${name}`,
|
||||
intro:
|
||||
'Tik op het hartje bij de foto’s die je wilt. Je selectie wordt meteen opgeslagen — je kunt met deze link altijd terugkomen.',
|
||||
saving: 'Opslaan…',
|
||||
saved: 'Selectie opgeslagen',
|
||||
saveFailed:
|
||||
'Je selectie kon niet worden opgeslagen. Controleer je verbinding en probeer het opnieuw.',
|
||||
review: 'Bekijken & versturen',
|
||||
modalTitle: (n: number) => `Jouw selectie (${n})`,
|
||||
empty: 'Nog geen foto’s geselecteerd.',
|
||||
submit: 'Selectie versturen',
|
||||
submitting: 'Versturen…',
|
||||
confirmSubmit: (n: number) =>
|
||||
`${plural(n, 'foto', 'foto’s')} versturen? Daarna kun je de selectie niet meer wijzigen.`,
|
||||
submitted: 'Bedankt — je selectie is verstuurd.',
|
||||
locked: 'Deze selectie is verstuurd en kan niet meer worden gewijzigd.',
|
||||
validUntil: (date: string) => `Deze link is geldig tot ${date}.`,
|
||||
expiredTitle: 'Deze link is verlopen',
|
||||
expiredText: 'Vraag je fotograaf om een nieuwe link.',
|
||||
downloadSelection: 'Selectie downloaden (.zip)',
|
||||
downloadAll: 'Alle foto’s downloaden (.zip)',
|
||||
downloadsLeft: (n: number) => `Nog ${plural(n, 'download', 'downloads')}`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Colofon',
|
||||
title: 'Colofon',
|
||||
|
||||
@@ -62,3 +62,32 @@ export function resolveImageSize(sizeParam: string | null, widthParam: string |
|
||||
explicit && fromWidth ? smaller(explicit, fromWidth) : (explicit ?? fromWidth ?? 'preview');
|
||||
return smaller(resolved, MAX_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `?w=` written for each tier: the widest value that still resolves to it.
|
||||
* `resolveImageSize(size, String(TIER_WIDTH[t]))` is `t` for every tier the
|
||||
* ceiling allows, so the canonical URL fetches exactly the rendition the
|
||||
* original width did.
|
||||
*/
|
||||
const TIER_WIDTH: Record<ImageSize, number> = { thumbnail: 250, preview: 1440, original: 3840 };
|
||||
|
||||
/**
|
||||
* One URL per asset and tier, whatever width is asked for.
|
||||
*
|
||||
* The proxy answers every width inside a tier with the same bytes — Immich
|
||||
* has one thumbnail and one preview per asset, and `MAX_SIZE` caps the rest —
|
||||
* but each distinct `?w=` is a separate `immutable` cache entry. next/image
|
||||
* writes eight or more widths into every srcset and the lightbox asked for the
|
||||
* bare URL, so a grid tile and the same photo in the lightbox, or one tile
|
||||
* before and after a rotation, downloaded the identical preview twice.
|
||||
* Collapsing the width to its tier makes all of them one cache entry. `q` is
|
||||
* dropped for the same reason: the proxy never read it.
|
||||
*
|
||||
* `width` defaults to "as large as the ceiling allows" — the lightbox's case.
|
||||
*/
|
||||
export function canonicalImageUrl(src: string, width: number = Number.MAX_SAFE_INTEGER): string {
|
||||
const queryAt = src.indexOf('?');
|
||||
const sizeParam = queryAt === -1 ? null : new URLSearchParams(src.slice(queryAt + 1)).get('size');
|
||||
const tier = resolveImageSize(sizeParam, String(Math.max(1, Math.round(width))));
|
||||
return `${src}${queryAt === -1 ? '?' : '&'}w=${TIER_WIDTH[tier]}`;
|
||||
}
|
||||
|
||||
304
lib/immich.ts
304
lib/immich.ts
@@ -11,7 +11,7 @@
|
||||
* cached answer may still be served in ./immichCache (#610).
|
||||
*/
|
||||
|
||||
import { getConfig, albumSlug, normalizeSlug, type SubpageConfig } from './config';
|
||||
import { getConfig, albumSlug, normalizeSlug, type AppConfig, type SubpageConfig } from './config';
|
||||
import { cache } from './cache';
|
||||
import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
|
||||
import { ImmichUnavailableError, isTimeout, requestJson } from './immichTransport';
|
||||
@@ -88,6 +88,8 @@ export type ImageSize = 'thumbnail' | 'preview' | 'original';
|
||||
/** Enriched subpage with album metadata (for rendering cards). */
|
||||
export interface SubpageSummary {
|
||||
name: string;
|
||||
/** The display title from gallery.yaml; visitors see this, `name` only as fallback. */
|
||||
title?: string;
|
||||
slug: string;
|
||||
albumCount: number;
|
||||
totalAssetCount: number;
|
||||
@@ -96,6 +98,16 @@ export interface SubpageSummary {
|
||||
|
||||
// Note: Album summary logging state is kept in the client instance.
|
||||
|
||||
/** Immich's album list as cached, with the allowlist it was fetched for. */
|
||||
interface CachedAlbumList {
|
||||
albums: ImmichAlbum[];
|
||||
fetchedFor: string;
|
||||
}
|
||||
|
||||
function allowlistKey(config: AppConfig): string {
|
||||
return [...config.albums].sort().join(',');
|
||||
}
|
||||
|
||||
// ── API Client ─────────────────────────────────────────────────
|
||||
|
||||
class ImmichClient {
|
||||
@@ -105,6 +117,11 @@ class ImmichClient {
|
||||
private pendingAlbumPromises = new Map<string, Promise<ImmichAlbum | null>>();
|
||||
private pendingAssetPromises = new Map<string, Promise<ImmichAsset | null>>();
|
||||
|
||||
/**
|
||||
* getConfig() re-checks the content files and hands back a fresh copy on
|
||||
* every call, so a method reads it once into a local and uses that, rather
|
||||
* than going through this getter for each property.
|
||||
*/
|
||||
private get config() {
|
||||
return getConfig();
|
||||
}
|
||||
@@ -118,8 +135,8 @@ class ImmichClient {
|
||||
* 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;
|
||||
private hasCredentials(config: AppConfig): boolean {
|
||||
const { apiUrl, apiKey } = config.immich;
|
||||
return !!apiUrl && !!apiKey;
|
||||
}
|
||||
|
||||
@@ -134,8 +151,8 @@ class ImmichClient {
|
||||
}
|
||||
|
||||
/** Cache write that carries the configured stale window. */
|
||||
private cacheSet<T>(key: string, data: T): void {
|
||||
cacheSetWithStale(key, data, this.config.cacheTtl, this.config.staleMaxAge);
|
||||
private cacheSet<T>(key: string, data: T, config: AppConfig = this.config): void {
|
||||
cacheSetWithStale(key, data, config.cacheTtl, config.staleMaxAge);
|
||||
}
|
||||
|
||||
/** See ./immichCache — the policy lives there, testable on its own. */
|
||||
@@ -156,15 +173,16 @@ class ImmichClient {
|
||||
* deployment rather than about HTTP.
|
||||
*/
|
||||
private async request<T>(endpoint: string, body?: unknown): Promise<T | null> {
|
||||
if (!this.hasCredentials) {
|
||||
const config = this.config;
|
||||
if (!this.hasCredentials(config)) {
|
||||
this.warnNoCredentials(endpoint);
|
||||
return null;
|
||||
}
|
||||
|
||||
return requestJson<T>({
|
||||
apiUrl: this.config.immich.apiUrl,
|
||||
apiKey: this.config.immich.apiKey,
|
||||
timeoutMs: this.config.immichTimeoutMs,
|
||||
apiUrl: config.immich.apiUrl,
|
||||
apiKey: config.immich.apiKey,
|
||||
timeoutMs: config.immichTimeoutMs,
|
||||
endpoint,
|
||||
body,
|
||||
});
|
||||
@@ -184,21 +202,22 @@ class ImmichClient {
|
||||
contentRange: string | null;
|
||||
status: 200 | 206;
|
||||
} | null> {
|
||||
if (!this.hasCredentials) return null;
|
||||
const config = this.config;
|
||||
if (!this.hasCredentials(config)) return null;
|
||||
|
||||
const endpoint = `/assets/${encodeURIComponent(assetId)}/video/playback`;
|
||||
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
||||
const url = `${config.immich.apiUrl}${endpoint}`;
|
||||
|
||||
// Bound the wait for response *headers* only. The `finally` clears the timer
|
||||
// the moment they arrive, so the body may then stream for as long as it
|
||||
// needs — a whole-request timeout would truncate playback mid-video and
|
||||
// break seeking, since every range request would restart the clock.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.config.immichTimeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), config.immichTimeoutMs);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'x-api-key': this.config.immich.apiKey,
|
||||
'x-api-key': config.immich.apiKey,
|
||||
};
|
||||
if (rangeHeader) {
|
||||
headers['Range'] = rangeHeader;
|
||||
@@ -235,13 +254,13 @@ class ImmichClient {
|
||||
if (error instanceof ImmichUnavailableError) throw error;
|
||||
console.error(
|
||||
isTimeout(error)
|
||||
? `[Immich] Video ${assetId} did not respond within ${this.config.immichTimeoutMs}ms`
|
||||
? `[Immich] Video ${assetId} did not respond within ${config.immichTimeoutMs}ms`
|
||||
: `[Immich] Video stream error for ${assetId}:`,
|
||||
error,
|
||||
);
|
||||
throw new ImmichUnavailableError(
|
||||
isTimeout(error)
|
||||
? `Immich did not respond within ${this.config.immichTimeoutMs}ms for video ${assetId}`
|
||||
? `Immich did not respond within ${config.immichTimeoutMs}ms for video ${assetId}`
|
||||
: `Cannot reach Immich to stream video ${assetId}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -256,24 +275,25 @@ class ImmichClient {
|
||||
assetId: string,
|
||||
size: ImageSize = 'preview',
|
||||
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
|
||||
if (!this.hasCredentials) return null;
|
||||
const config = this.config;
|
||||
if (!this.hasCredentials(config)) return null;
|
||||
|
||||
const endpoint =
|
||||
size === 'original'
|
||||
? `/assets/${encodeURIComponent(assetId)}/original`
|
||||
: `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
|
||||
|
||||
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
||||
const url = `${config.immich.apiUrl}${endpoint}`;
|
||||
|
||||
// Headers-only timeout, same reasoning as streamVideo: an original-size
|
||||
// photo is legitimately slow to transfer and must not be capped.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.config.immichTimeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), config.immichTimeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'x-api-key': this.config.immich.apiKey,
|
||||
'x-api-key': config.immich.apiKey,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -305,13 +325,13 @@ class ImmichClient {
|
||||
if (error instanceof ImmichUnavailableError) throw error;
|
||||
console.error(
|
||||
isTimeout(error)
|
||||
? `[Immich] Asset ${assetId} did not respond within ${this.config.immichTimeoutMs}ms`
|
||||
? `[Immich] Asset ${assetId} did not respond within ${config.immichTimeoutMs}ms`
|
||||
: `[Immich] Stream error for ${assetId}:`,
|
||||
error,
|
||||
);
|
||||
throw new ImmichUnavailableError(
|
||||
isTimeout(error)
|
||||
? `Immich did not respond within ${this.config.immichTimeoutMs}ms for asset ${assetId}`
|
||||
? `Immich did not respond within ${config.immichTimeoutMs}ms for asset ${assetId}`
|
||||
: `Cannot reach Immich to stream asset ${assetId}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -320,10 +340,63 @@ class ImmichClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ALL configured albums (filtered by the full allowlist).
|
||||
* Uses ?shared=true to only fetch albums that have been shared in Immich.
|
||||
* Every published album: the allowlist applied to Immich's album list, with
|
||||
* the gallery.yaml title and description overrides and the resulting slug.
|
||||
*
|
||||
* The cache holds Immich's list as Immich sent it; the allowlist and the
|
||||
* overrides are applied here, on every call, from the current config. They
|
||||
* used to be applied before caching, and the admin routes' invalidateAll()
|
||||
* runs in their own module instances (Next bundles each route separately),
|
||||
* so after a save the public pages kept the old titles, descriptions and
|
||||
* album set until CACHE_TTL ran out. getConfig() already follows the
|
||||
* content files' mtimes into every instance, so deriving from it per call
|
||||
* makes a save visible on the next request, at no Immich cost.
|
||||
*
|
||||
* A newly allowlisted album that is missing from the cached list (created
|
||||
* in Immich after the list was fetched) refetches the list once per
|
||||
* allowlist, instead of 404-ing until the TTL runs out; see listIsCurrent().
|
||||
*
|
||||
* `?shared=true` is inert: current Immich ignores it and answers with every
|
||||
* album (#515). The allowlist is the whole protection.
|
||||
*/
|
||||
async getAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
|
||||
const config = this.config;
|
||||
const list = await this.loadAlbumList(forceFresh, config);
|
||||
return this.publishedAlbums(list, config);
|
||||
}
|
||||
|
||||
/** The allowlisted albums of a raw Immich list, named per gallery.yaml. */
|
||||
private publishedAlbums(all: readonly ImmichAlbum[], config: AppConfig): ImmichAlbum[] {
|
||||
const allowedIds = new Set(config.albums);
|
||||
return all
|
||||
.filter((album) => allowedIds.has(album.id))
|
||||
.map((album) => this.withOverrides(album, config));
|
||||
}
|
||||
|
||||
/**
|
||||
* An album named as gallery.yaml says. Returns a copy: the argument is the
|
||||
* cached entry, shared by every caller.
|
||||
*/
|
||||
private withOverrides(album: ImmichAlbum, config: AppConfig): ImmichAlbum {
|
||||
const name = config.albumOverrides[album.id] ?? album.albumName;
|
||||
const description = config.albumDescriptions[album.id] ?? album.description ?? '';
|
||||
return { ...album, albumName: name, description, slug: albumSlug(name, album.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cached list can answer for this allowlist: it holds every
|
||||
* allowlisted album, or it was already fetched for this exact allowlist
|
||||
* (an album deleted in Immich stays missing, and must not cost a request
|
||||
* per page view).
|
||||
*/
|
||||
private listIsCurrent(entry: CachedAlbumList, config: AppConfig): boolean {
|
||||
if (entry.fetchedFor === allowlistKey(config)) return true;
|
||||
const known = new Set(entry.albums.map((a) => a.id));
|
||||
return config.albums.every((id) => known.has(id));
|
||||
}
|
||||
|
||||
/** Immich's album list, cached as sent. */
|
||||
private async loadAlbumList(forceFresh: boolean, config: AppConfig): Promise<ImmichAlbum[]> {
|
||||
const cacheKey = 'albums-list';
|
||||
// forceFresh only skips the read below; it must not delete the entry
|
||||
// outright. Deleting first meant a request that then failed left every
|
||||
@@ -331,8 +404,8 @@ class ImmichClient {
|
||||
// from the same key, so the old entry has to survive until cacheSet()
|
||||
// overwrites it on success.
|
||||
if (!forceFresh) {
|
||||
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
const cached = cache.get<CachedAlbumList>(cacheKey);
|
||||
if (cached && this.listIsCurrent(cached, config)) return cached.albums;
|
||||
}
|
||||
|
||||
if (this.pendingAlbumsPromise) {
|
||||
@@ -344,66 +417,20 @@ class ImmichClient {
|
||||
const all = await this.request<ImmichAlbum[]>('/albums?shared=true');
|
||||
if (!all) return [];
|
||||
|
||||
const allowedIds = new Set(this.config.albums);
|
||||
const filtered = all
|
||||
.filter((album) => allowedIds.has(album.id))
|
||||
.map((album) => {
|
||||
const name = this.config.albumOverrides[album.id] ?? album.albumName;
|
||||
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
|
||||
return {
|
||||
...album,
|
||||
albumName: name,
|
||||
description,
|
||||
slug: albumSlug(name, album.id),
|
||||
};
|
||||
});
|
||||
|
||||
// Log album summary on first load so admins can see what's published
|
||||
if (!this.hasLoggedAlbums) {
|
||||
this.hasLoggedAlbums = true;
|
||||
console.log('\n[Lightbox] Published albums:');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
// Log standalone albums
|
||||
const standaloneIds = new Set(this.config.standaloneAlbums);
|
||||
const standalone = filtered.filter((a) => standaloneIds.has(a.id));
|
||||
if (standalone.length > 0) {
|
||||
console.log(' Standalone:');
|
||||
for (const a of standalone) {
|
||||
console.log(` 📷 ${a.albumName}`);
|
||||
console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log subpage groupings
|
||||
for (const sp of this.config.subpages) {
|
||||
const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id));
|
||||
console.log(` 📁 ${sp.name} (/${sp.slug}):`);
|
||||
for (const a of spAlbums) {
|
||||
console.log(` 📷 ${a.albumName}`);
|
||||
console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`);
|
||||
}
|
||||
}
|
||||
|
||||
const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id));
|
||||
if (missing.length > 0) {
|
||||
console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`);
|
||||
}
|
||||
console.log('─'.repeat(80) + '\n');
|
||||
this.logPublishedAlbums(all, config);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Cached even while setup is unfinished: the list is Immich's, and the
|
||||
// setup config's empty allowlist is applied on the way out, not baked
|
||||
// into the entry.
|
||||
const entry: CachedAlbumList = { albums: all, fetchedFor: allowlistKey(config) };
|
||||
this.cacheSet(cacheKey, entry, config);
|
||||
return all;
|
||||
} catch (error) {
|
||||
return this.staleOrThrow<ImmichAlbum[]>(cacheKey, error, 'album list');
|
||||
return this.staleOrThrow<CachedAlbumList>(cacheKey, error, 'album list').albums;
|
||||
} finally {
|
||||
this.pendingAlbumsPromise = null;
|
||||
}
|
||||
@@ -412,6 +439,40 @@ class ImmichClient {
|
||||
return this.pendingAlbumsPromise;
|
||||
}
|
||||
|
||||
/** The published albums, once per process, so admins can see what is live. */
|
||||
private logPublishedAlbums(all: readonly ImmichAlbum[], config: AppConfig): void {
|
||||
const filtered = this.publishedAlbums(all, config);
|
||||
console.log('\n[Lightbox] Published albums:');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
// Log standalone albums
|
||||
const standaloneIds = new Set(config.standaloneAlbums);
|
||||
const standalone = filtered.filter((a) => standaloneIds.has(a.id));
|
||||
if (standalone.length > 0) {
|
||||
console.log(' Standalone:');
|
||||
for (const a of standalone) {
|
||||
console.log(` 📷 ${a.albumName}`);
|
||||
console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log subpage groupings
|
||||
for (const sp of config.subpages) {
|
||||
const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id));
|
||||
console.log(` 📁 ${sp.name} (/${sp.slug}):`);
|
||||
for (const a of spAlbums) {
|
||||
console.log(` 📷 ${a.albumName}`);
|
||||
console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`);
|
||||
}
|
||||
}
|
||||
|
||||
const missing = config.albums.filter((id) => !all.some((a) => a.id === id));
|
||||
if (missing.length > 0) {
|
||||
console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`);
|
||||
}
|
||||
console.log('─'.repeat(80) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only standalone albums (not in any subpage) — for the homepage.
|
||||
*/
|
||||
@@ -446,6 +507,7 @@ class ImmichClient {
|
||||
|
||||
return {
|
||||
name: sp.name,
|
||||
...(sp.title ? { title: sp.title } : {}),
|
||||
slug: sp.slug,
|
||||
albumCount: spAlbums.length,
|
||||
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
|
||||
@@ -465,9 +527,13 @@ class ImmichClient {
|
||||
const subpage = this.config.subpages.find((sp) => sp.slug === wanted && sp.enabled !== false);
|
||||
if (!subpage) return null;
|
||||
|
||||
const allAlbums = await this.getAlbums(forceFresh);
|
||||
const subpageAlbumIds = new Set(subpage.albumIds);
|
||||
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
|
||||
// In the subpage's own order (gallery.yaml, as arranged in the page
|
||||
// builder), not Immich's: filtering the list kept Immich's order, so the
|
||||
// cover grid ignored every rearrangement.
|
||||
const byId = new Map((await this.getAlbums(forceFresh)).map((a) => [a.id, a]));
|
||||
const albums = [...new Set(subpage.albumIds)]
|
||||
.map((id) => byId.get(id))
|
||||
.filter((a): a is ImmichAlbum => a !== undefined);
|
||||
|
||||
return { subpage, albums };
|
||||
}
|
||||
@@ -526,20 +592,23 @@ class ImmichClient {
|
||||
* saved config, which is right for the public site but wrong for the page
|
||||
* builder: an album just dragged in has not been saved yet, so the picker
|
||||
* and the reorder editor would come up empty. Admin auth is the gate here.
|
||||
*
|
||||
* The public site uses it too: album blocks in journal entries, content
|
||||
* pages and essay files, and the published-asset set that covers them. The
|
||||
* author's pick is the gate there, not the allowlist.
|
||||
*
|
||||
* It goes through the same cached, coalesced load as getAlbum(), so those
|
||||
* renders cost no Immich request within CACHE_TTL, and an album that is also
|
||||
* allowlisted is fetched once for both. That load yields exactly this list —
|
||||
* trashed assets dropped, sorted by capture time in the album's own order —
|
||||
* which is the order the site renders under `sort: immich`, and the baseline
|
||||
* the reorder editor has to match. `forceFresh` skips the cached read for
|
||||
* the admin editors, which must show an album as Immich has it now.
|
||||
*/
|
||||
async getAlbumAssetsRaw(albumId: string): Promise<ImmichAsset[]> {
|
||||
const [album, assets] = await Promise.all([
|
||||
this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`),
|
||||
this.fetchAlbumAssets(albumId),
|
||||
]);
|
||||
if (!album) return [];
|
||||
|
||||
// Same order the site would render under `sort: immich`. The reorder
|
||||
// editor's baseline has to match it exactly, or the assets it shows as
|
||||
// "follows automatically" would not be the ones that actually follow.
|
||||
return assets
|
||||
.filter((a) => !a.isTrashed)
|
||||
.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
|
||||
async getAlbumAssetsRaw(albumId: string, forceFresh = false): Promise<ImmichAsset[]> {
|
||||
const album = await this.loadAlbum(albumId, forceFresh, true);
|
||||
// A copy: the cached entry is shared with every other caller.
|
||||
return album ? [...album.assets] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,13 +624,17 @@ class ImmichClient {
|
||||
* be sorting the same array.
|
||||
*/
|
||||
private withSort(album: ImmichAlbum): ImmichAlbum {
|
||||
const mode = this.config.albumSortModes[album.id] ?? DEFAULT_ALBUM_SORT;
|
||||
const config = this.config;
|
||||
const mode = config.albumSortModes[album.id] ?? DEFAULT_ALBUM_SORT;
|
||||
return {
|
||||
...album,
|
||||
// Titles and descriptions the same way, for the same reason: the cache
|
||||
// holds the album as Immich has it, so a gallery.yaml override takes
|
||||
// effect on the next request, in every module instance.
|
||||
...this.withOverrides(album, config),
|
||||
assets: sortAlbumAssets(album.assets, {
|
||||
mode,
|
||||
immichOrder: album.order,
|
||||
manualOrder: this.config.albumManualOrders[album.id],
|
||||
manualOrder: config.albumManualOrders[album.id],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -571,10 +644,28 @@ class ImmichClient {
|
||||
return album ? this.withSort(album) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An album for a client proofing link, whether or not it is published.
|
||||
*
|
||||
* Client galleries are usually not part of the public portfolio, so the
|
||||
* allowlist cannot be the gate here. The proofing session is: callers reach
|
||||
* this only after resolving a valid, unexpired session token, and pass that
|
||||
* session's own album ID — never one taken from the request.
|
||||
*/
|
||||
async getProofingAlbum(albumId: string): Promise<ImmichAlbum | null> {
|
||||
const album = await this.loadAlbum(albumId, false, true);
|
||||
return album ? this.withSort(album) : null;
|
||||
}
|
||||
|
||||
/** The cached load. Always yields the canonical Immich order; see withSort(). */
|
||||
private async loadAlbum(albumId: string, forceFresh: boolean): Promise<ImmichAlbum | null> {
|
||||
private async loadAlbum(
|
||||
albumId: string,
|
||||
forceFresh: boolean,
|
||||
/** Only for getProofingAlbum() and getAlbumAssetsRaw(); see there. */
|
||||
bypassAllowlist = false,
|
||||
): Promise<ImmichAlbum | null> {
|
||||
// Security: only serve configured albums
|
||||
if (!this.config.albums.includes(albumId)) {
|
||||
if (!bypassAllowlist && !this.config.albums.includes(albumId)) {
|
||||
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
|
||||
return null;
|
||||
}
|
||||
@@ -627,12 +718,8 @@ class ImmichClient {
|
||||
// getAlbum(); see withSort().
|
||||
album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
|
||||
|
||||
const name = this.config.albumOverrides[album.id] ?? album.albumName;
|
||||
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
|
||||
album.albumName = name;
|
||||
album.description = description;
|
||||
album.slug = albumSlug(name, album.id);
|
||||
|
||||
// Cached without the gallery.yaml title and description: withSort()
|
||||
// applies them on the way out, from the current config.
|
||||
this.cacheSet(cacheKey, album);
|
||||
return album;
|
||||
} catch (error) {
|
||||
@@ -662,16 +749,17 @@ class ImmichClient {
|
||||
): Promise<ImmichAlbum | null> {
|
||||
const albums = await this.getAlbums(forceFresh);
|
||||
|
||||
const config = this.config;
|
||||
let routeIds: Set<string>;
|
||||
if (subpageSlug) {
|
||||
const wantedSubpage = normalizeSlug(subpageSlug);
|
||||
const subpage = this.config.subpages.find(
|
||||
const subpage = config.subpages.find(
|
||||
(sp) => sp.slug === wantedSubpage && sp.enabled !== false,
|
||||
);
|
||||
if (!subpage) return null;
|
||||
routeIds = new Set(subpage.albumIds);
|
||||
} else {
|
||||
routeIds = new Set(this.config.standaloneAlbums);
|
||||
routeIds = new Set(config.standaloneAlbums);
|
||||
}
|
||||
const searchSet = albums.filter((a) => routeIds.has(a.id));
|
||||
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
* Custom next/image loader for Immich proxy.
|
||||
*
|
||||
* Referenced by next.config.ts via images.loaderFile.
|
||||
* Generates URLs that point to our /api/image proxy route,
|
||||
* passing width and quality as query parameters.
|
||||
* Generates URLs that point to our /api/image proxy route.
|
||||
*
|
||||
* The src passed to next/image should already be a full proxy URL
|
||||
* (e.g. /api/image/<token>?size=preview). This loader appends
|
||||
* w and q params for responsive srcset generation.
|
||||
* (e.g. /api/image/<token>?size=preview). This loader appends the width —
|
||||
* collapsed to the width of the Immich tier it resolves to, so every srcset
|
||||
* candidate of one tier is the same URL (see canonicalImageUrl).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { canonicalImageUrl } from './imageSize';
|
||||
|
||||
interface ImageLoaderParams {
|
||||
src: string;
|
||||
width: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export default function immichLoader({ src, width, quality }: ImageLoaderParams): string {
|
||||
const separator = src.includes('?') ? '&' : '?';
|
||||
return `${src}${separator}w=${width}&q=${quality || 75}`;
|
||||
export default function immichLoader({ src, width }: ImageLoaderParams): string {
|
||||
return canonicalImageUrl(src, width);
|
||||
}
|
||||
|
||||
@@ -125,8 +125,13 @@ interface InstallFileData {
|
||||
|
||||
let cachedInstallFile: { path: string; mtimeMs: number; data: InstallFileData } | null = null;
|
||||
|
||||
/** Where install.json lives; getConfig() watches its mtime. */
|
||||
export function installFilePath(): string {
|
||||
return path.join(installContentDir(), INSTALL_FILENAME);
|
||||
}
|
||||
|
||||
function readInstallFile(): InstallFileData {
|
||||
const installPath = path.join(installContentDir(), INSTALL_FILENAME);
|
||||
const installPath = installFilePath();
|
||||
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
|
||||
23
lib/journal.server.ts
Normal file
23
lib/journal.server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Journal reads for server rendering, memoised for the length of one request.
|
||||
*
|
||||
* A journal page reads the same files from several places in one render: the
|
||||
* header nav (`loadSiteNav()` in the root layout) lists every entry to decide
|
||||
* whether Journal belongs in the menu, the page lists them again for its
|
||||
* index or its prev/next links, and an entry's `generateMetadata()` reads the
|
||||
* entry the page body reads a moment later. React's `cache()` hands all of
|
||||
* them one result per request — each request still reads the files afresh,
|
||||
* so an edit shows on the next page load as before.
|
||||
*
|
||||
* Render only: outside a React server render `cache()` does not memoise, and
|
||||
* admin routes, which read back what they just wrote, keep calling
|
||||
* `lib/admin/journal-service` directly. Callers must not mutate the results,
|
||||
* which are shared.
|
||||
*/
|
||||
|
||||
import { cache } from 'react';
|
||||
import { listJournalEntries, readJournalEntry } from './admin/journal-service';
|
||||
|
||||
export const listJournalEntriesForRequest = cache(listJournalEntries);
|
||||
|
||||
export const readJournalEntryForRequest = cache(readJournalEntry);
|
||||
114
lib/journal.ts
114
lib/journal.ts
@@ -6,6 +6,8 @@
|
||||
export interface JournalFrontmatter {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** SEO description. Content pages use it (#722); journal entries ignore it. */
|
||||
description?: string;
|
||||
date?: string;
|
||||
author?: string;
|
||||
coverAssetId?: string;
|
||||
@@ -184,14 +186,28 @@ export function isValidSlug(slug: string): boolean {
|
||||
}
|
||||
|
||||
export function sanitizeSlug(input: string): string {
|
||||
return (
|
||||
input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'untitled'
|
||||
);
|
||||
return slugFieldValue(input.trim()).replace(/-$/, '') || 'untitled';
|
||||
}
|
||||
|
||||
/**
|
||||
* A slug field's value while it is being typed: sanitizeSlug() without the two
|
||||
* steps that only suit a finished slug. Dropping the trailing "-" on every
|
||||
* keystroke made a hyphen impossible to type ("about-" became "about", and the
|
||||
* next key gave "aboutu"), and an emptied field jumped to "untitled". Pass the
|
||||
* result through sanitizeSlug() before using it as a slug.
|
||||
*
|
||||
* Accented letters keep their base letter ("für" → "fur", "Straße" →
|
||||
* "strasse"), as subpage slugs do; before, each one split the word ("f-r").
|
||||
*/
|
||||
export function slugFieldValue(input: string): string {
|
||||
return input
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-/, '');
|
||||
}
|
||||
|
||||
/** Simple Markdown inline formatting (bold, italic, links) */
|
||||
@@ -216,8 +232,11 @@ export function renderInlineMarkdown(text: string): string {
|
||||
// Italic: *text* or _text_, same intraword rule for `_`.
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/(?<![\w])_(.*?)_(?![\w])/g, '<em>$1</em>');
|
||||
// Links: [label](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
|
||||
// Links: [label](url). The URL may hold one level of balanced parentheses,
|
||||
// as Wikipedia-style URLs do (`…/Foo_(bar)`); it used to end at the first
|
||||
// `)` and leave the rest as text. The two alternatives start on disjoint
|
||||
// characters, so this stays linear.
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)/g, (match, label, url) => {
|
||||
const trimmed = url.trim();
|
||||
if (!isSafeUrl(trimmed)) return label;
|
||||
return `<a href="${trimmed}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
@@ -365,6 +384,7 @@ export function parseFrontmatter(content: string): {
|
||||
|
||||
if (key === 'title') frontmatter.title = val;
|
||||
else if (key === 'subtitle') frontmatter.subtitle = val;
|
||||
else if (key === 'description') frontmatter.description = val;
|
||||
else if (key === 'date') frontmatter.date = val;
|
||||
else if (key === 'author') frontmatter.author = val;
|
||||
else if (key === 'coverAssetId') frontmatter.coverAssetId = val;
|
||||
@@ -376,6 +396,63 @@ export function parseFrontmatter(content: string): {
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a chunk before every line that starts a photo reference (`![`).
|
||||
*
|
||||
* Paragraphs are separated by blank lines, so two photo lines written without
|
||||
* one, or a heading or sentence directly above a photo, used to arrive as a
|
||||
* single chunk. No block pattern matched it and it fell through to the
|
||||
* paragraph renderer, which printed the raw asset ids on the public page.
|
||||
* Directive chunks (`::map`, `::facts`, `::album`) are left alone: their
|
||||
* lines are key/value pairs, never photo references.
|
||||
*/
|
||||
function splitAtPhotoLines(chunk: string): string[] {
|
||||
if (chunk.startsWith('::') || !/(?:^|\n)[ \t]*!\[/.test(chunk)) return [chunk];
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const line of chunk.split(/\r?\n/)) {
|
||||
if (line.trimStart().startsWith('![') && current.length > 0) {
|
||||
segments.push(current.join('\n').trim());
|
||||
current = [];
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
segments.push(current.join('\n').trim());
|
||||
return segments.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a photo line, ``, into target and caption.
|
||||
*
|
||||
* The target ends at the first `]` on the line: asset ids, layouts and commas
|
||||
* never contain one. The caption is everything after `](` up to the chunk's
|
||||
* **last** `)`, so it may itself contain parentheses and links (`Gipfel
|
||||
* (560 m)`, `see [map](https://…)`) and needs no escaping. The previous
|
||||
* pattern, `\(([^)]*)\)$`, stopped at the first `)`. The serializer wrote such
|
||||
* captions verbatim, the next parse rejected the line, the public page printed
|
||||
* it as a paragraph with the raw asset ids, and the editor turned the photo
|
||||
* into a text block. A line that pattern accepted has no `)` inside its
|
||||
* caption, so it parses exactly as before.
|
||||
*
|
||||
* Every chunk starting with `![` is a photo line, even a malformed one (no
|
||||
* `(`, text after the last `)`): it becomes a photo block whose caption keeps
|
||||
* the stray text, never a paragraph that would print the target.
|
||||
*/
|
||||
function parsePhotoLine(chunk: string): { target: string; caption: string } | null {
|
||||
if (!chunk.startsWith('![')) return null;
|
||||
const firstLineEnd = chunk.indexOf('\n');
|
||||
const firstLine = firstLineEnd === -1 ? chunk : chunk.slice(0, firstLineEnd);
|
||||
const close = firstLine.indexOf(']');
|
||||
const targetEnd = close === -1 ? firstLine.length : close;
|
||||
const target = chunk.slice(2, targetEnd).trim();
|
||||
let rest = chunk.slice(close === -1 ? targetEnd : targetEnd + 1);
|
||||
if (rest.startsWith('(')) {
|
||||
rest = rest.slice(1);
|
||||
if (rest.endsWith(')')) rest = rest.slice(0, -1);
|
||||
}
|
||||
return { target, caption: rest.trim() };
|
||||
}
|
||||
|
||||
/** Parse Journal Markdown content into structured blocks */
|
||||
export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
const { frontmatter, body } = parseFrontmatter(rawContent);
|
||||
@@ -385,7 +462,8 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
const chunks = body
|
||||
.split(/\r?\n\s*\r?\n/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
.filter(Boolean)
|
||||
.flatMap(splitAtPhotoLines);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
// 0a. Map: `::map Caption`, then one line per pin in drawing order —
|
||||
@@ -526,10 +604,12 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
// unfilled placeholder — `assetId: ''`, serialized as `` or
|
||||
// `` — still parses back into a `photo` block instead of
|
||||
// silently degrading into a text paragraph on the next load.
|
||||
const imgMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
|
||||
if (imgMatch) {
|
||||
const rawTarget = imgMatch[1].trim();
|
||||
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
|
||||
//
|
||||
// parsePhotoLine() has the caption run to the last `)`, see there.
|
||||
const photoLine = parsePhotoLine(chunk);
|
||||
if (photoLine) {
|
||||
const rawTarget = photoLine.target;
|
||||
const caption = photoLine.caption ? renderInlineMarkdown(photoLine.caption) : undefined;
|
||||
|
||||
// Two ids are a side-by-side pair, three or more a grid. The pair used
|
||||
// to take the first two of any count and drop the rest on the next save.
|
||||
@@ -704,7 +784,9 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
|
||||
export function calculateReadingTime(text: string): { words: number; minutes: number } {
|
||||
const plainText = text
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, ' ')
|
||||
// A photo line is a reference and its caption, to the end of the line:
|
||||
// the caption may itself contain `)`.
|
||||
.replace(/^[ \t]*!\[.*$/gm, ' ')
|
||||
// Directive lines (`::facts`, `::map …`) are structure, not reading.
|
||||
.replace(/^::\w+.*$/gm, ' ');
|
||||
const words = plainText.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
49
lib/journalDate.ts
Normal file
49
lib/journalDate.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Display form of a journal entry's `date:` frontmatter. Client-safe (no `fs`),
|
||||
* shared by the /journal index and the entry header so both show the same thing.
|
||||
*
|
||||
* The value is typed by hand, so it is read as a calendar date, not an instant:
|
||||
*
|
||||
* - `2026-03-15` (optionally followed by a time) is formatted in UTC. `new Date()`
|
||||
* reads a date-only ISO string as UTC midnight, and formatting that in the
|
||||
* server's or browser's timezone showed Mar 14 west of Greenwich.
|
||||
* - `2026-03` shows month and year — `new Date('2026-03')` invented the 1st.
|
||||
* - Anything else (`15.03.2026`, `March 2026`, `Spring 2026`, an impossible
|
||||
* `2026-02-30`) is shown as written instead of "Invalid Date" or a guessed day.
|
||||
*/
|
||||
const DAY = /^(\d{4})-(\d{2})-(\d{2})(?=$|[T\s])/;
|
||||
const MONTH = /^(\d{4})-(\d{2})$/;
|
||||
|
||||
export function formatJournalDate(raw: string | undefined, locale: string): string | null {
|
||||
const value = raw?.trim();
|
||||
if (!value) return null;
|
||||
|
||||
const day = DAY.exec(value);
|
||||
if (day) {
|
||||
const [y, m, d] = [Number(day[1]), Number(day[2]), Number(day[3])];
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
// Date.UTC rolls 2026-02-30 over into March; refuse instead of moving it.
|
||||
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(locale, {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
const month = MONTH.exec(value);
|
||||
if (month) {
|
||||
const [y, m] = [Number(month[1]), Number(month[2])];
|
||||
if (m < 1 || m > 12) return value;
|
||||
return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(locale, {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { immich } from './immich';
|
||||
import { getConfig } from './config';
|
||||
import { isListedSubpage, listedAlbumIds } from './config/schema';
|
||||
import { cache } from './cache';
|
||||
|
||||
/**
|
||||
@@ -44,13 +45,21 @@ export async function getMapData(): Promise<MapLocation[]> {
|
||||
|
||||
pendingMapDataPromise = (async () => {
|
||||
try {
|
||||
const albums = await immich.getAlbums();
|
||||
// The allowlist still holds the albums of subpages taken offline with
|
||||
// `enabled: false`, and those of `hidden` subpages, which are reachable
|
||||
// by direct link only. A marker names, links and shows a photo of every
|
||||
// album it counts — a listing — so both have to go before anything is
|
||||
// aggregated.
|
||||
const listed = listedAlbumIds(config);
|
||||
const albums = (await immich.getAlbums()).filter((a) => listed.has(a.id));
|
||||
|
||||
// Build a lookup: album ID → { name, slug, subpageSlug? }
|
||||
const albumMeta = new Map<string, { name: string; slug: string; subpageSlug?: string }>();
|
||||
for (const a of albums) {
|
||||
// Check if this album belongs to a subpage
|
||||
const sp = config.subpages.find((s) => s.albumIds.includes(a.id));
|
||||
// The subpage the marker links to, and whose password gates it. Never
|
||||
// an offline one (its URL is a 404 and its password guards nothing),
|
||||
// and never a hidden one: the marker would publish its address.
|
||||
const sp = config.subpages.find((s) => isListedSubpage(s) && s.albumIds.includes(a.id));
|
||||
albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
|
||||
}
|
||||
|
||||
|
||||
66
lib/ogImage.ts
Normal file
66
lib/ogImage.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Signed URLs for the generated share card (`/api/og`).
|
||||
*
|
||||
* The card prints whatever title and subtitle its URL carries, on the site's
|
||||
* own domain and in its colours. Unsigned, anyone could have the site render
|
||||
* text of their choosing — a fake notice passed around as the site's own
|
||||
* image — and every new string cost a fresh satori render. The pages that
|
||||
* emit the URL sign the text they put in it; the route renders only text that
|
||||
* carries a valid signature and falls back to the plain site card otherwise.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { getConfig } from './config';
|
||||
|
||||
export const OG_TITLE_MAX = 200;
|
||||
export const OG_SUBTITLE_MAX = 100;
|
||||
|
||||
/** Truncates by code point, so a cut never leaves half a surrogate pair. */
|
||||
function clip(text: string, max: number): string {
|
||||
const chars = Array.from(text);
|
||||
return chars.length > max ? chars.slice(0, max).join('') : text;
|
||||
}
|
||||
|
||||
function sign(secret: string, title: string, subtitle: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`og-card\0${title}\0${subtitle}`)
|
||||
.digest('base64url')
|
||||
.slice(0, 22);
|
||||
}
|
||||
|
||||
/** `/api/og` URL for a card showing `title` (and `subtitle`), signed. */
|
||||
export function ogImageUrl(title: string, subtitle = ''): string {
|
||||
let secret: string;
|
||||
try {
|
||||
secret = getConfig().authSecret;
|
||||
} catch {
|
||||
// No secret to sign with — the route then shows the site title anyway.
|
||||
return '/api/og';
|
||||
}
|
||||
const t = clip(title, OG_TITLE_MAX);
|
||||
const s = clip(subtitle, OG_SUBTITLE_MAX);
|
||||
const params = new URLSearchParams({ title: t });
|
||||
if (s) params.set('subtitle', s);
|
||||
params.set('sig', sign(secret, t, s));
|
||||
return `/api/og?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The title and subtitle of a card URL, or null unless the URL was produced
|
||||
* by `ogImageUrl()` with this secret.
|
||||
*/
|
||||
export function verifiedOgText(
|
||||
params: URLSearchParams,
|
||||
secret: string,
|
||||
): { title: string; subtitle: string } | null {
|
||||
const title = params.get('title');
|
||||
const subtitle = params.get('subtitle') ?? '';
|
||||
const sig = params.get('sig');
|
||||
if (!title || !sig || !secret) return null;
|
||||
|
||||
const expected = Buffer.from(sign(secret, title, subtitle));
|
||||
const given = Buffer.from(sig);
|
||||
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null;
|
||||
return { title, subtitle };
|
||||
}
|
||||
140
lib/pages.ts
Normal file
140
lib/pages.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Content pages (#722): timeless pages such as pricing, workshops or a booking
|
||||
* FAQ, stored one per file in `content/pages/<slug>.md` and served at
|
||||
* `/<slug>`.
|
||||
*
|
||||
* Client-safe (no `fs`) — the admin UI imports the slug rules from here. The
|
||||
* file access lives in lib/admin/pages-service.ts.
|
||||
*
|
||||
* A page is block markdown in the journal format (lib/journal.ts), with a
|
||||
* smaller frontmatter: `title`, `description` (SEO), `password`, `draft`.
|
||||
* Whether it appears in the menu is not a property of the file: it is decided
|
||||
* by a `- page: <slug>` entry among the subpages in gallery.yaml, so the menu
|
||||
* order lives in exactly one place.
|
||||
*/
|
||||
|
||||
import { isValidSlug, type JournalFrontmatter } from './journal';
|
||||
|
||||
export type PageFrontmatter = Pick<
|
||||
JournalFrontmatter,
|
||||
'title' | 'description' | 'password' | 'draft'
|
||||
>;
|
||||
|
||||
export interface PageSummary {
|
||||
slug: string;
|
||||
frontmatter: PageFrontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* First path segments the app serves itself. A page slugged like one of them
|
||||
* would never be reached — the fixed route wins — so it is refused outright.
|
||||
* `proof` and `contact` are fixed routes too, beyond the list in the issue.
|
||||
*/
|
||||
export const RESERVED_PAGE_SLUGS: readonly string[] = [
|
||||
'about',
|
||||
'map',
|
||||
'journal',
|
||||
'impressum',
|
||||
'privacy',
|
||||
'install',
|
||||
'admin',
|
||||
'gate',
|
||||
'api',
|
||||
'proof',
|
||||
'contact',
|
||||
];
|
||||
|
||||
/** Everything a page slug must not collide with. */
|
||||
export interface SlugTakenBy {
|
||||
subpages: readonly string[];
|
||||
albums: readonly string[];
|
||||
journal: readonly string[];
|
||||
}
|
||||
|
||||
export type SlugCollision =
|
||||
| { kind: 'invalid' }
|
||||
| { kind: 'reserved' }
|
||||
| { kind: 'subpage' }
|
||||
| { kind: 'album' }
|
||||
| { kind: 'journal' };
|
||||
|
||||
/**
|
||||
* Why `slug` cannot be a page's URL, or null when it can.
|
||||
*
|
||||
* Journal entries live under /journal/ and cannot shadow a page, but they are
|
||||
* checked all the same (#722): a page and an entry of the same name are two
|
||||
* things a visitor would expect at one address.
|
||||
*/
|
||||
export function pageSlugCollision(slug: string, taken: SlugTakenBy): SlugCollision | null {
|
||||
if (!isValidSlug(slug)) return { kind: 'invalid' };
|
||||
const wanted = slug.toLowerCase();
|
||||
if (RESERVED_PAGE_SLUGS.includes(wanted)) return { kind: 'reserved' };
|
||||
if (taken.subpages.some((s) => s.toLowerCase() === wanted)) return { kind: 'subpage' };
|
||||
if (taken.albums.some((s) => s.toLowerCase() === wanted)) return { kind: 'album' };
|
||||
if (taken.journal.some((s) => s.toLowerCase() === wanted)) return { kind: 'journal' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A sentence for the admin UI and the doctor. */
|
||||
export function describeCollision(slug: string, collision: SlugCollision): string {
|
||||
switch (collision.kind) {
|
||||
case 'invalid':
|
||||
return `"${slug}" is not a valid slug: use letters, digits, "-" and "_" only.`;
|
||||
case 'reserved':
|
||||
return `/${slug} is a built-in route of the site.`;
|
||||
case 'subpage':
|
||||
return `/${slug} is already a subpage.`;
|
||||
case 'album':
|
||||
return `/${slug} is already a standalone album.`;
|
||||
case 'journal':
|
||||
return `A journal entry already uses the slug "${slug}".`;
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of the header menu, in gallery.yaml order. */
|
||||
export type NavEntry = { type: 'subpage'; slug: string } | { type: 'page'; slug: string };
|
||||
|
||||
/** The page slugs referenced from gallery.yaml, i.e. the pages shown in the menu. */
|
||||
export function menuPageSlugs(nav: readonly NavEntry[]): string[] {
|
||||
return nav.filter((e) => e.type === 'page').map((e) => e.slug);
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
key: string;
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header menu's subpage and page links, in gallery.yaml order.
|
||||
*
|
||||
* `visibleSubpages` is what the menu may show (hidden and disabled subpages
|
||||
* already dropped). A page reference whose file is missing, or that is still a
|
||||
* draft, is left out the same way. A password-protected page stays listed, as
|
||||
* a protected subpage does: the gate is at the page, not in the menu.
|
||||
*/
|
||||
export function resolveMenu(
|
||||
nav: readonly NavEntry[],
|
||||
visibleSubpages: ReadonlyArray<{ slug: string; name: string; title?: string }>,
|
||||
pages: readonly PageSummary[],
|
||||
): MenuItem[] {
|
||||
const subpageBySlug = new Map(visibleSubpages.map((sp) => [sp.slug, sp]));
|
||||
const pageBySlug = new Map(pages.map((p) => [p.slug, p]));
|
||||
return nav.flatMap((entry): MenuItem[] => {
|
||||
if (entry.type === 'subpage') {
|
||||
const sp = subpageBySlug.get(entry.slug);
|
||||
// The title a visitor sees on the page itself; `name` is the key the
|
||||
// slug is made from, and only stands in when no title is set.
|
||||
return sp ? [{ key: `sp-${sp.slug}`, href: `/${sp.slug}`, label: sp.title || sp.name }] : [];
|
||||
}
|
||||
const page = pageBySlug.get(entry.slug);
|
||||
if (!page || page.frontmatter.draft) return [];
|
||||
return [
|
||||
{
|
||||
key: `page-${page.slug}`,
|
||||
href: `/${page.slug}`,
|
||||
label: page.frontmatter.title || page.slug,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,29 @@ export async function verifyScrypt(password: string, stored: string): Promise<bo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A well-formed hash with a random salt and a random digest, drawn once per
|
||||
* process. No password matches it (the odds are 2^-512), so verifying against
|
||||
* it costs exactly what a real verification costs and always fails.
|
||||
*/
|
||||
const DUMMY_HASH = `${SCRYPT_PREFIX}${crypto.randomBytes(16).toString('hex')}:${crypto
|
||||
.randomBytes(64)
|
||||
.toString('hex')}`;
|
||||
|
||||
/**
|
||||
* Spend one scrypt verification on nothing.
|
||||
*
|
||||
* A password check that returns early — no such key, a key without a password,
|
||||
* a plaintext password compared by HMAC — answers in a couple of milliseconds,
|
||||
* while a real scrypt hash takes tens. That gap tells anyone who times the
|
||||
* replies which keys hold a hashed password, even when the reply bodies are
|
||||
* identical. Callers run this on every path that would otherwise skip scrypt,
|
||||
* so every attempt costs the same.
|
||||
*/
|
||||
export async function burnScrypt(password: string): Promise<void> {
|
||||
await verifyScrypt(password, DUMMY_HASH);
|
||||
}
|
||||
|
||||
/** Produce a storable `scrypt:salt:hash_hex` string. */
|
||||
export async function generateScryptHash(password: string): Promise<string> {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
83
lib/proofAccess.ts
Normal file
83
lib/proofAccess.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The shared preamble of every public proofing route: rate limit, site lock,
|
||||
* the session behind the link token, and the album it was created for.
|
||||
*
|
||||
* The album ID always comes from the stored session, never from the request,
|
||||
* which is what makes it safe to load an unpublished album here.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { immich, ImmichUnavailableError, type ImmichAsset } from './immich';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from './rate-limit';
|
||||
import { siteLockResponse } from './auth';
|
||||
import { decodeAssetId } from './tokens';
|
||||
import { findSessionByToken, isExpired, type ProofSession } from './proofSessions';
|
||||
|
||||
export type ProofAccess =
|
||||
{ error: Response } | { session: ProofSession; albumName: string; assets: ImmichAsset[] };
|
||||
|
||||
const noStore = { 'Cache-Control': 'no-store' };
|
||||
|
||||
export function proofError(status: number, error: string, extra?: Record<string, string>) {
|
||||
return NextResponse.json({ error }, { status, headers: { ...noStore, ...extra } });
|
||||
}
|
||||
|
||||
export async function resolveProofAccess(
|
||||
request: NextRequest,
|
||||
token: string,
|
||||
bucket: string,
|
||||
rpm: number,
|
||||
): Promise<ProofAccess> {
|
||||
const ip = getClientIp(request);
|
||||
const rl = checkRateLimit(`${bucket}:${ip}`, rpm);
|
||||
if (!rl.success) {
|
||||
return {
|
||||
error: proofError(429, 'Too many requests', {
|
||||
'Retry-After': String(retryAfterSeconds(rl.resetAt)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const locked = siteLockResponse(request);
|
||||
if (locked) return { error: locked };
|
||||
|
||||
const session = await findSessionByToken(token);
|
||||
// Unknown and expired answer the same, so a guessed token learns nothing.
|
||||
if (!session || isExpired(session)) return { error: proofError(404, 'Not found') };
|
||||
|
||||
let album;
|
||||
try {
|
||||
album = await immich.getProofingAlbum(session.albumId);
|
||||
} catch (error) {
|
||||
if (error instanceof ImmichUnavailableError) {
|
||||
return { error: proofError(503, 'Immich is currently unavailable', { 'Retry-After': '30' }) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!album) return { error: proofError(404, 'Not found') };
|
||||
|
||||
return {
|
||||
session,
|
||||
albumName: album.albumName,
|
||||
assets: album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset tokens from the client → Immich asset IDs of this album, in album
|
||||
* order. Null when any token does not decode or names an asset outside the
|
||||
* album: a token is unforgeable, but membership is what keeps one session
|
||||
* from recording another album's photos.
|
||||
*/
|
||||
export function decodeSelection(tokens: unknown, assets: ImmichAsset[]): string[] | null {
|
||||
if (!Array.isArray(tokens)) return null;
|
||||
const inAlbum = new Set(assets.map((a) => a.id));
|
||||
const picked = new Set<string>();
|
||||
for (const token of tokens) {
|
||||
if (typeof token !== 'string') return null;
|
||||
const id = decodeAssetId(token);
|
||||
if (!id || !inAlbum.has(id)) return null;
|
||||
picked.add(id);
|
||||
}
|
||||
return assets.filter((a) => picked.has(a.id)).map((a) => a.id);
|
||||
}
|
||||
56
lib/proofExport.ts
Normal file
56
lib/proofExport.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Turning a submitted selection into something the photographer can act on.
|
||||
*
|
||||
* The client picked from web previews; the photographer edits RAW files. So
|
||||
* the most useful export is the list of file names *without* their extension:
|
||||
* pasted into Lightroom's or Capture One's filename filter, it matches
|
||||
* IMG_0412.CR3 as readily as IMG_0412.JPG.
|
||||
*/
|
||||
|
||||
export interface ExportAsset {
|
||||
/** 1-based position in the album, as the client saw it. */
|
||||
position: number;
|
||||
fileName: string;
|
||||
takenAt?: string;
|
||||
}
|
||||
|
||||
export type ExportFormat = 'lightroom' | 'txt' | 'csv';
|
||||
|
||||
/** `IMG_0412.JPG` → `IMG_0412`. A name without a dot stays as it is. */
|
||||
export function baseName(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf('.');
|
||||
return dot > 0 ? fileName.slice(0, dot) : fileName;
|
||||
}
|
||||
|
||||
/** Quote a CSV field when it needs it (RFC 4180), and defuse spreadsheet formulas. */
|
||||
function csvField(value: string): string {
|
||||
// A leading =, +, - or @ makes Excel and LibreOffice evaluate the cell. File
|
||||
// names come from the client's camera, not from us — never let one run.
|
||||
// Tab and CR start a formula in some spreadsheet apps as well (OWASP).
|
||||
const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
||||
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
||||
}
|
||||
|
||||
export function formatExport(assets: ExportAsset[], format: ExportFormat): string {
|
||||
switch (format) {
|
||||
case 'lightroom':
|
||||
// De-duplicated: a RAW+JPEG pair uploaded to Immich shares one base name.
|
||||
return Array.from(new Set(assets.map((a) => baseName(a.fileName)))).join(', ');
|
||||
case 'txt':
|
||||
return assets.map((a) => a.fileName).join('\n') + (assets.length ? '\n' : '');
|
||||
case 'csv': {
|
||||
const rows = [['position', 'file_name', 'taken_at']].concat(
|
||||
assets.map((a) => [String(a.position), a.fileName, a.takenAt ?? '']),
|
||||
);
|
||||
return rows.map((row) => row.map(csvField).join(',')).join('\r\n') + '\r\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function exportContentType(format: ExportFormat): string {
|
||||
return format === 'csv' ? 'text/csv; charset=utf-8' : 'text/plain; charset=utf-8';
|
||||
}
|
||||
|
||||
export function exportExtension(format: ExportFormat): string {
|
||||
return format === 'csv' ? 'csv' : 'txt';
|
||||
}
|
||||
319
lib/proofSessions.ts
Normal file
319
lib/proofSessions.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Client proofing sessions — "Proofing 2.0".
|
||||
*
|
||||
* The anonymous proofing on public albums keeps everything in the visitor's
|
||||
* browser: favourites in localStorage, a bitmask in the URL, and a mailto the
|
||||
* photographer may or may not receive. A proofing session is the handover
|
||||
* version of that: the photographer creates a link for one client and one
|
||||
* album, the client's picks are saved here as they make them, and a final
|
||||
* "submit" locks the selection and tells the photographer.
|
||||
*
|
||||
* Storage is one JSON file, `content/proofing.json`, next to analytics.json —
|
||||
* a portfolio has a handful of these at a time, not thousands. The file holds
|
||||
* the link tokens, which are capabilities, so it is written 0600.
|
||||
*
|
||||
* The link token is the only credential a client has. It is 192 random bits,
|
||||
* compared as an exact string lookup, and never derived from anything else.
|
||||
* Admin routes address a session by its separate, non-secret `id`, so an admin
|
||||
* URL in a log or a browser history does not leak a working client link.
|
||||
*
|
||||
* Selections are stored as Immich asset IDs. Clients only ever see the opaque
|
||||
* asset tokens (lib/tokens.ts); the routes decode them and check that every
|
||||
* one belongs to the session's album before anything is written.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { atomicWrite } from './atomicWrite';
|
||||
import { isUuid } from './uuid';
|
||||
|
||||
/** What a session lets its client download as a ZIP of originals. */
|
||||
export type ProofDownload = 'none' | 'selection' | 'album';
|
||||
|
||||
export interface ProofSession {
|
||||
/** Non-secret handle for the admin panel. */
|
||||
id: string;
|
||||
/** The client link's capability: /proof/<token>. */
|
||||
token: string;
|
||||
/** Who the link is for, as the photographer wants to see it. */
|
||||
clientName: string;
|
||||
albumId: string;
|
||||
createdAt: string;
|
||||
/** Last day the link works (YYYY-MM-DD, inclusive), or absent for no expiry. */
|
||||
expiresOn?: string;
|
||||
download: ProofDownload;
|
||||
/** How many ZIP downloads the link allows; absent means unlimited. */
|
||||
downloadLimit?: number;
|
||||
downloadsUsed: number;
|
||||
/** Selected Immich asset IDs, in the order the client picked them. */
|
||||
selection: string[];
|
||||
/** When the client last changed the selection. */
|
||||
updatedAt?: string;
|
||||
/** Set once the client submits; the selection is read-only from then on. */
|
||||
submittedAt?: string;
|
||||
}
|
||||
|
||||
interface ProofStore {
|
||||
sessions: ProofSession[];
|
||||
}
|
||||
|
||||
/** The largest selection a session stores — far above any real proofing round. */
|
||||
export const MAX_SELECTION = 2000;
|
||||
|
||||
/** Upper bound on sessions, so a runaway script cannot grow the file forever. */
|
||||
export const MAX_SESSIONS = 500;
|
||||
|
||||
// ── Pure helpers ────────────────────────────────────────────────────
|
||||
|
||||
export type ProofState = 'open' | 'submitted' | 'expired';
|
||||
|
||||
/**
|
||||
* Whether a session still works on `now`. `expiresOn` is inclusive: a link
|
||||
* "valid until 12 May" still opens on the evening of 12 May, in the server's
|
||||
* time zone.
|
||||
*/
|
||||
export function isExpired(session: Pick<ProofSession, 'expiresOn'>, now = new Date()): boolean {
|
||||
if (!session.expiresOn) return false;
|
||||
const end = new Date(`${session.expiresOn}T23:59:59.999`);
|
||||
return Number.isNaN(end.getTime()) ? false : now.getTime() > end.getTime();
|
||||
}
|
||||
|
||||
export function sessionState(session: ProofSession, now = new Date()): ProofState {
|
||||
if (isExpired(session, now)) return 'expired';
|
||||
return session.submittedAt ? 'submitted' : 'open';
|
||||
}
|
||||
|
||||
/** ZIP downloads left, or null for unlimited. */
|
||||
export function downloadsRemaining(session: ProofSession): number | null {
|
||||
if (session.downloadLimit === undefined) return null;
|
||||
return Math.max(0, session.downloadLimit - session.downloadsUsed);
|
||||
}
|
||||
|
||||
/** A 192-bit URL-safe token. */
|
||||
export function newToken(): string {
|
||||
return crypto.randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32}$/;
|
||||
|
||||
/** Cheap shape check before any file is read for a token from a URL. */
|
||||
export function isTokenShaped(token: string): boolean {
|
||||
return TOKEN_PATTERN.test(token);
|
||||
}
|
||||
|
||||
/** Input the admin panel may send when creating or editing a session. */
|
||||
export const sessionInputSchema = z.object({
|
||||
clientName: z.string().trim().min(1).max(120),
|
||||
albumId: z.string().refine(isUuid, 'Not an Immich album ID'),
|
||||
expiresOn: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
download: z.enum(['none', 'selection', 'album']).default('none'),
|
||||
downloadLimit: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type SessionInput = z.infer<typeof sessionInputSchema>;
|
||||
|
||||
export const sessionPatchSchema = sessionInputSchema
|
||||
.omit({ albumId: true })
|
||||
.partial()
|
||||
.extend({
|
||||
/** Unlock a submitted selection so the client can change it again. */
|
||||
reopen: z.literal(true).optional(),
|
||||
/** Reset the download counter, e.g. after a failed download. */
|
||||
resetDownloads: z.literal(true).optional(),
|
||||
});
|
||||
|
||||
export type SessionPatch = z.infer<typeof sessionPatchSchema>;
|
||||
|
||||
// ── Storage ─────────────────────────────────────────────────────────
|
||||
|
||||
function storeFile(): string {
|
||||
return path.join(process.cwd(), 'content', 'proofing.json');
|
||||
}
|
||||
|
||||
async function readStore(): Promise<ProofStore> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(storeFile(), 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { sessions: [] };
|
||||
throw err;
|
||||
}
|
||||
// A file that exists but does not parse is not treated as empty: the next
|
||||
// write would replace every session with nothing. Let the caller fail.
|
||||
const parsed = JSON.parse(raw) as Partial<ProofStore>;
|
||||
return { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialises read-modify-write within this process, like the analytics store:
|
||||
* the atomic rename keeps the file whole, but without the queue two concurrent
|
||||
* saves would each write their own copy and one client's heart would vanish.
|
||||
*/
|
||||
let writeQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function mutate<T>(fn: (store: ProofStore) => T): Promise<T> {
|
||||
const run = writeQueue.then(async () => {
|
||||
const store = await readStore();
|
||||
const result = fn(store);
|
||||
await fs.mkdir(path.dirname(storeFile()), { recursive: true });
|
||||
await atomicWrite(storeFile(), JSON.stringify(store, null, 2), { mode: 0o600 });
|
||||
return result;
|
||||
});
|
||||
// The queue must survive a failed write, or every later save would reject too.
|
||||
writeQueue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<ProofSession[]> {
|
||||
return (await readStore()).sessions;
|
||||
}
|
||||
|
||||
export async function getSessionById(id: string): Promise<ProofSession | null> {
|
||||
return (await readStore()).sessions.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time token comparison. With 192 random bits a timing attack is not
|
||||
* practical, but the link token is the client's only credential and the
|
||||
* comparison costs nothing to harden.
|
||||
*/
|
||||
function tokenMatches(stored: string, given: string): boolean {
|
||||
const a = Buffer.from(stored);
|
||||
const b = Buffer.from(given);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function findSessionByToken(token: string): Promise<ProofSession | null> {
|
||||
if (!isTokenShaped(token)) return null;
|
||||
return (await readStore()).sessions.find((s) => tokenMatches(s.token, token)) ?? null;
|
||||
}
|
||||
|
||||
export class ProofError extends Error {
|
||||
constructor(
|
||||
public readonly code: 'not-found' | 'expired' | 'submitted' | 'limit' | 'too-many' | 'empty',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(input: SessionInput): Promise<ProofSession> {
|
||||
return mutate((store) => {
|
||||
if (store.sessions.length >= MAX_SESSIONS) {
|
||||
throw new ProofError('too-many', `At most ${MAX_SESSIONS} proofing links can exist.`);
|
||||
}
|
||||
const session: ProofSession = {
|
||||
id: crypto.randomBytes(8).toString('hex'),
|
||||
token: newToken(),
|
||||
clientName: input.clientName,
|
||||
albumId: input.albumId,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(input.expiresOn ? { expiresOn: input.expiresOn } : {}),
|
||||
download: input.download,
|
||||
...(input.downloadLimit ? { downloadLimit: input.downloadLimit } : {}),
|
||||
downloadsUsed: 0,
|
||||
selection: [],
|
||||
};
|
||||
store.sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSession(id: string, patch: SessionPatch): Promise<ProofSession> {
|
||||
return mutate((store) => {
|
||||
const session = store.sessions.find((s) => s.id === id);
|
||||
if (!session) throw new ProofError('not-found', 'No such proofing link.');
|
||||
|
||||
if (patch.clientName !== undefined) session.clientName = patch.clientName;
|
||||
if (patch.download !== undefined) session.download = patch.download;
|
||||
// `null` clears, `undefined` leaves alone.
|
||||
if (patch.expiresOn === null) delete session.expiresOn;
|
||||
else if (patch.expiresOn !== undefined) session.expiresOn = patch.expiresOn;
|
||||
if (patch.downloadLimit === null) delete session.downloadLimit;
|
||||
else if (patch.downloadLimit !== undefined) session.downloadLimit = patch.downloadLimit;
|
||||
if (patch.reopen) delete session.submittedAt;
|
||||
if (patch.resetDownloads) session.downloadsUsed = 0;
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
await mutate((store) => {
|
||||
const before = store.sessions.length;
|
||||
store.sessions = store.sessions.filter((s) => s.id !== id);
|
||||
if (store.sessions.length === before) {
|
||||
throw new ProofError('not-found', 'No such proofing link.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Find the session for a token and refuse anything that may not change. */
|
||||
function openSession(store: ProofStore, token: string): ProofSession {
|
||||
const session = isTokenShaped(token)
|
||||
? store.sessions.find((s) => tokenMatches(s.token, token))
|
||||
: undefined;
|
||||
if (!session) throw new ProofError('not-found', 'No such proofing link.');
|
||||
if (isExpired(session)) throw new ProofError('expired', 'This proofing link has expired.');
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the client's selection. The caller has already checked that every ID
|
||||
* belongs to the session's album; this re-checks the session's own state inside
|
||||
* the write lock, so a save racing a submit cannot slip in after it.
|
||||
*/
|
||||
export async function saveSelection(token: string, assetIds: string[]): Promise<ProofSession> {
|
||||
return mutate((store) => {
|
||||
const session = openSession(store, token);
|
||||
if (session.submittedAt) {
|
||||
throw new ProofError('submitted', 'This selection has already been submitted.');
|
||||
}
|
||||
session.selection = Array.from(new Set(assetIds)).slice(0, MAX_SELECTION);
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the selection. Returns the session and whether this call was the one
|
||||
* that submitted it, so a double click notifies the photographer only once.
|
||||
*/
|
||||
export async function submitSelection(
|
||||
token: string,
|
||||
): Promise<{ session: ProofSession; firstSubmit: boolean }> {
|
||||
return mutate((store) => {
|
||||
const session = openSession(store, token);
|
||||
if (session.submittedAt) return { session, firstSubmit: false };
|
||||
// Checked here, under the write lock: a save of an empty selection racing
|
||||
// the submit would otherwise lock nothing and still fire the webhook.
|
||||
if (session.selection.length === 0) throw new ProofError('empty', 'Nothing selected.');
|
||||
session.submittedAt = new Date().toISOString();
|
||||
return { session, firstSubmit: true };
|
||||
});
|
||||
}
|
||||
|
||||
/** Count one ZIP download against the link's limit, refusing past it. */
|
||||
export async function claimDownload(
|
||||
token: string,
|
||||
scope: 'selection' | 'album' = 'selection',
|
||||
): Promise<ProofSession> {
|
||||
return mutate((store) => {
|
||||
const session = openSession(store, token);
|
||||
// Re-checked under the lock: the admin may have narrowed the link since
|
||||
// the request read its snapshot.
|
||||
if (session.download === 'none' || (scope === 'album' && session.download !== 'album')) {
|
||||
throw new ProofError('not-found', 'This proofing link offers no downloads.');
|
||||
}
|
||||
if (session.downloadLimit !== undefined && session.downloadsUsed >= session.downloadLimit) {
|
||||
throw new ProofError('limit', 'The download limit for this link has been reached.');
|
||||
}
|
||||
session.downloadsUsed += 1;
|
||||
return session;
|
||||
});
|
||||
}
|
||||
105
lib/proofWebhook.ts
Normal file
105
lib/proofWebhook.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Tell the photographer that a client submitted a proofing selection.
|
||||
*
|
||||
* One POST to `PROOFING_WEBHOOK_URL` with a JSON body shaped to be useful to
|
||||
* as many receivers as possible without a per-service setting: Discord reads
|
||||
* `content`, Slack and Mattermost read `text`, Gotify reads `title` and
|
||||
* `message`, and n8n, Home Assistant or a script of your own get the
|
||||
* structured `proofing` object.
|
||||
*
|
||||
* Fire-and-forget by design. A webhook that is down must never turn a client's
|
||||
* submit into an error — the selection is already saved and visible in the
|
||||
* admin panel — so failures are logged and swallowed, and the request is
|
||||
* bounded by a short timeout.
|
||||
*/
|
||||
|
||||
import type { ProofSession } from './proofSessions';
|
||||
|
||||
const WEBHOOK_TIMEOUT_MS = 5000;
|
||||
|
||||
export interface ProofWebhookPayload {
|
||||
event: 'proofing.submitted';
|
||||
title: string;
|
||||
message: string;
|
||||
text: string;
|
||||
content: string;
|
||||
proofing: {
|
||||
id: string;
|
||||
clientName: string;
|
||||
albumName: string;
|
||||
selected: number;
|
||||
submittedAt: string;
|
||||
/** The admin page for this session, when a site URL is configured. */
|
||||
adminUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebhookPayload(
|
||||
session: ProofSession,
|
||||
albumName: string,
|
||||
siteUrl: string | null,
|
||||
): ProofWebhookPayload {
|
||||
const count = session.selection.length;
|
||||
const title = `Proofing: ${session.clientName} submitted a selection`;
|
||||
const adminUrl = siteUrl ? `${siteUrl}/admin/proofing?session=${session.id}` : null;
|
||||
const message =
|
||||
`${session.clientName} picked ${count} ${count === 1 ? 'photo' : 'photos'} from "${albumName}".` +
|
||||
(adminUrl ? ` ${adminUrl}` : '');
|
||||
return {
|
||||
event: 'proofing.submitted',
|
||||
title,
|
||||
message,
|
||||
text: `${title}\n${message}`,
|
||||
content: `**${title}**\n${message}`,
|
||||
proofing: {
|
||||
id: session.id,
|
||||
clientName: session.clientName,
|
||||
albumName,
|
||||
selected: count,
|
||||
submittedAt: session.submittedAt ?? new Date().toISOString(),
|
||||
adminUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse PROOFING_WEBHOOK_URL; anything that is not an http(s) URL is ignored. */
|
||||
export function webhookUrl(raw = process.env.PROOFING_WEBHOOK_URL): string | null {
|
||||
const value = raw?.trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifySubmitted(
|
||||
session: ProofSession,
|
||||
albumName: string,
|
||||
siteUrl: string | null,
|
||||
): Promise<void> {
|
||||
const url = webhookUrl();
|
||||
if (!url) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildWebhookPayload(session, albumName, siteUrl)),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[Proofing] Webhook answered ${res.status}; the selection is saved regardless.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[Proofing] Webhook failed; the selection is saved regardless:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,15 @@ export interface PublicSubpage {
|
||||
export interface PublicJournalEntry {
|
||||
slug: string;
|
||||
draft: boolean;
|
||||
/** The entry carries a password of its own. */
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
export interface PublicContentPage {
|
||||
slug: string;
|
||||
draft: boolean;
|
||||
/** The page carries a password of its own. */
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
export interface SiteShape {
|
||||
@@ -38,6 +47,8 @@ export interface SiteShape {
|
||||
/** Albums that are not part of any subpage. */
|
||||
standaloneAlbums: PublicAlbum[];
|
||||
journal: PublicJournalEntry[];
|
||||
/** Content pages (#722), in or out of the menu — both are public URLs. */
|
||||
pages?: PublicContentPage[];
|
||||
/** Whether an album carries its own password. Keyed by Immich album id. */
|
||||
isAlbumProtected: (albumId: string) => boolean;
|
||||
aboutEnabled: boolean;
|
||||
@@ -81,11 +92,19 @@ export function publicPaths(site: SiteShape): string[] {
|
||||
paths.push(`/${album.slug}`);
|
||||
}
|
||||
|
||||
for (const page of site.pages ?? []) {
|
||||
// A page outside the menu is still a public page; a draft or a locked
|
||||
// one is not, and its title would travel with the URL.
|
||||
if (page.draft || page.isProtected) continue;
|
||||
paths.push(`/${page.slug}`);
|
||||
}
|
||||
|
||||
if (site.journalEnabled) {
|
||||
for (const entry of site.journal) {
|
||||
// Drafts stay visible to the logged-in admin and absent for everyone
|
||||
// else; a listing is "everyone else".
|
||||
if (entry.draft) continue;
|
||||
// else; a listing is "everyone else". A locked entry is left out for
|
||||
// the reason /journal hides it: its URL leads to a gate that names it.
|
||||
if (entry.draft || entry.isProtected) continue;
|
||||
paths.push(`/journal/${entry.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
252
lib/publishedAssets.ts
Normal file
252
lib/publishedAssets.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* The set of Immich assets the public site currently shows somewhere.
|
||||
*
|
||||
* Asset tokens are deterministic, so a media URL that was ever served stays
|
||||
* decodable. Without a membership check, /api/image, /api/video and /api/exif
|
||||
* kept streaming an asset after its album was unpublished or a proofing link
|
||||
* was revoked (GHSA-gfh4-6275-9gqv). The media routes now ask this module.
|
||||
*
|
||||
* What counts as published — every source that turns an asset ID into a
|
||||
* public URL:
|
||||
* - the assets and thumbnail of every standalone album and every album of
|
||||
* an enabled subpage; a subpage taken offline with `enabled: false` keeps
|
||||
* its albums on the allowlist, but publishes nothing
|
||||
* - the homepage hero list, and the hero image of each album above
|
||||
* - the about.md portrait
|
||||
* - every journal entry: cover, photo blocks, and the assets of its album
|
||||
* blocks (drafts and locked entries included: their page gate still hides
|
||||
* them, and the tokens cannot be guessed)
|
||||
* - every content page (#722): photo blocks and the assets of its album
|
||||
* blocks, drafts and locked pages included for the same reason; its map
|
||||
* blocks are not rendered, so their photos do not count
|
||||
* - album blocks in an enabled subpage's inline essayText
|
||||
* - the album of every open (unexpired) proofing link
|
||||
*
|
||||
* Built lazily, cached for CACHE_TTL, one build shared by concurrent callers.
|
||||
* The admin routes run in their own module instances, so a save cannot reach
|
||||
* this cache directly. Instead the set remembers the modification times of
|
||||
* the content files it was built from and rebuilds as soon as one changes —
|
||||
* so unpublishing an album or revoking a proofing link takes effect at once,
|
||||
* not after the TTL. A miss also forces one rebuild before answering "no".
|
||||
*
|
||||
* "At once" means: every answer comes from a set whose build began after the
|
||||
* request looked at the files. The files are stat'ed on every request (they
|
||||
* used to be checked at most every 2 s, and a request in that window was
|
||||
* answered — `immutable` — from the set before the save), and a build reads
|
||||
* the stamp before the content, so a save racing a build leaves the set
|
||||
* stamped older than the files instead of newer.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { getConfig } from './config';
|
||||
import { onlineAlbumIds } from './config/schema';
|
||||
import { immich } from './immich';
|
||||
import { collectAssetIds, parseJournalMarkdown, type JournalBlock } from './journal';
|
||||
import { listJournalEntries, readJournalEntry } from './admin/journal-service';
|
||||
import { listPages, readPage } from './admin/pages-service';
|
||||
import { isExpired, listSessions } from './proofSessions';
|
||||
|
||||
/** A forced rebuild on a miss at most this often, so misses cannot hammer Immich. */
|
||||
const MIN_REBUILD_MS = 10_000;
|
||||
|
||||
const CONTENT = path.join(process.cwd(), 'content');
|
||||
const WATCHED = [
|
||||
'gallery.yaml',
|
||||
'settings.yaml',
|
||||
'about.md',
|
||||
'proofing.json',
|
||||
'journal',
|
||||
'essays',
|
||||
'pages',
|
||||
];
|
||||
|
||||
let current: { assets: Set<string>; builtAt: number; stamp: string; startedAt: number } | null =
|
||||
null;
|
||||
let building: Promise<Set<string>> | null = null;
|
||||
let lastForcedAt = 0;
|
||||
/**
|
||||
* Orders file checks and build starts. A set whose `startedAt` is above a
|
||||
* request's check read the files after that request did, and so answers for
|
||||
* it whatever the stamps say.
|
||||
*/
|
||||
let clock = 0;
|
||||
|
||||
function albumBlockIds(blocks: readonly JournalBlock[]): string[] {
|
||||
return blocks.flatMap((b) => (b.type === 'album' && b.albumId ? [b.albumId] : []));
|
||||
}
|
||||
|
||||
async function aboutPortrait(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(process.cwd(), 'content', 'about.md'), 'utf-8');
|
||||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const meta = match ? (yaml.load(match[1]) as { portrait?: unknown } | null) : null;
|
||||
return typeof meta?.portrait === 'string' ? meta.portrait : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Modification times of everything the set is built from. */
|
||||
async function contentStamp(): Promise<string> {
|
||||
const times = await Promise.all(
|
||||
WATCHED.map((name) =>
|
||||
fs.stat(path.join(CONTENT, name)).then(
|
||||
(st) => st.mtimeMs,
|
||||
() => 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
return times.join(':');
|
||||
}
|
||||
|
||||
async function build(): Promise<Set<string>> {
|
||||
const config = getConfig();
|
||||
const assets = new Set<string>();
|
||||
const add = (id: string | null | undefined) => {
|
||||
if (id) assets.add(id);
|
||||
};
|
||||
const rawAlbums = new Set<string>();
|
||||
const online = onlineAlbumIds(config);
|
||||
|
||||
// Published albums. A failing album must not empty the whole set.
|
||||
await Promise.all(
|
||||
[...online].map(async (id) => {
|
||||
const album = await immich.getAlbum(id).catch(() => null);
|
||||
if (!album) return;
|
||||
add(album.albumThumbnailAssetId);
|
||||
for (const a of album.assets) add(a.id);
|
||||
}),
|
||||
);
|
||||
|
||||
config.heroImages.forEach(add);
|
||||
for (const [albumId, assetId] of Object.entries(config.albumHeroImages)) {
|
||||
if (online.has(albumId)) add(assetId);
|
||||
}
|
||||
add(await aboutPortrait());
|
||||
|
||||
// Journal entries, including drafts and locked ones (see above).
|
||||
const entries = await listJournalEntries().catch(() => []);
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const read = await readJournalEntry(entry.slug).catch(() => null);
|
||||
if (!read) return;
|
||||
add(read.parsed.frontmatter.coverAssetId);
|
||||
collectAssetIds(read.parsed.blocks).forEach(add);
|
||||
albumBlockIds(read.parsed.blocks).forEach((id) => rawAlbums.add(id));
|
||||
}),
|
||||
);
|
||||
|
||||
// Content pages, drafts and locked ones included. Map blocks are dropped
|
||||
// before a page renders, so their photos are not published through it.
|
||||
const pages = await listPages().catch(() => []);
|
||||
await Promise.all(
|
||||
pages.map(async (page) => {
|
||||
const read = await readPage(page.slug).catch(() => null);
|
||||
if (!read) return;
|
||||
const blocks = read.parsed.blocks.filter((b) => b.type !== 'map');
|
||||
collectAssetIds(blocks).forEach(add);
|
||||
albumBlockIds(blocks).forEach((id) => rawAlbums.add(id));
|
||||
}),
|
||||
);
|
||||
|
||||
// Inline essays in gallery.yaml: only their album blocks add assets; a photo
|
||||
// block outside the subpage's albums is dropped before it becomes a URL.
|
||||
for (const sp of config.subpages) {
|
||||
if (sp.enabled !== false && sp.essayText) {
|
||||
albumBlockIds(parseJournalMarkdown(sp.essayText).blocks).forEach((id) => rawAlbums.add(id));
|
||||
}
|
||||
}
|
||||
|
||||
// Open proofing links. Their albums are usually not published.
|
||||
const sessions = await listSessions().catch(() => []);
|
||||
const proofAlbums = new Set(sessions.filter((s) => !isExpired(s)).map((s) => s.albumId));
|
||||
await Promise.all(
|
||||
[...proofAlbums].map(async (id) => {
|
||||
const album = await immich.getProofingAlbum(id).catch(() => null);
|
||||
for (const a of album?.assets ?? []) add(a.id);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[...rawAlbums].map(async (id) => {
|
||||
const list = await immich.getAlbumAssetsRaw(id).catch(() => []);
|
||||
for (const a of list) add(a.id);
|
||||
}),
|
||||
);
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
function rebuild(): Promise<Set<string>> {
|
||||
if (!building) {
|
||||
const startedAt = ++clock;
|
||||
// Stamp first, content second: a save landing mid-build then leaves the
|
||||
// set stamped older than the files, and the next check rebuilds. Read in
|
||||
// parallel, the stamp could come back newer than the content it labels.
|
||||
building = contentStamp()
|
||||
.then(async (stamp) => {
|
||||
const assets = await build();
|
||||
current = { assets, builtAt: Date.now(), stamp, startedAt };
|
||||
return assets;
|
||||
})
|
||||
.finally(() => {
|
||||
building = null;
|
||||
});
|
||||
}
|
||||
return building;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the public site currently shows this asset anywhere.
|
||||
*
|
||||
* Fails open when the set cannot be built at all (Immich down on a cold
|
||||
* start): refusing every photo would take the site down, and the tokens are
|
||||
* unguessable either way. Once built, a stale set is kept through outages.
|
||||
*/
|
||||
export async function isPublishedAsset(assetId: string): Promise<boolean> {
|
||||
const ttlMs = getConfig().cacheTtl * 1000;
|
||||
try {
|
||||
const checkedAt = ++clock;
|
||||
const stamp = await contentStamp();
|
||||
const stale = () =>
|
||||
!current ||
|
||||
Date.now() - current.builtAt > ttlMs ||
|
||||
(current.stamp !== stamp && current.startedAt < checkedAt);
|
||||
// Twice at most: the first rebuild may join a build that started before
|
||||
// this check; any build started after that one finished is recent enough.
|
||||
for (let i = 0; i < 2 && stale(); i++) await rebuild();
|
||||
} catch {
|
||||
if (!current) return true;
|
||||
}
|
||||
if (current!.assets.has(assetId)) return true;
|
||||
|
||||
// Not in the set: it may have been published since the last build. A miss
|
||||
// while a rebuild is running waits for it — the first view of a new album is
|
||||
// a burst of misses, and refusing all but the one that started the rebuild
|
||||
// broke most of its photos. Only with nothing running does the throttle
|
||||
// decide whether a miss may start one.
|
||||
if (!building) {
|
||||
if (Date.now() - lastForcedAt < MIN_REBUILD_MS) return false;
|
||||
lastForcedAt = Date.now();
|
||||
}
|
||||
try {
|
||||
return (await rebuild()).has(assetId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the cached set, e.g. after a save that publishes or unpublishes photos. */
|
||||
export function invalidatePublishedAssets(): void {
|
||||
current = null;
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
export function resetPublishedAssetsForTest(): void {
|
||||
current = null;
|
||||
building = null;
|
||||
lastForcedAt = 0;
|
||||
clock = 0;
|
||||
}
|
||||
59
lib/requestBody.ts
Normal file
59
lib/requestBody.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Request bodies read with a hard size cap.
|
||||
*
|
||||
* `request.json()` parses the whole body before anything can object, which is
|
||||
* the one place an unbounded payload could land. These read the stream by
|
||||
* hand and stop the moment the cap is passed, so a chunked body is capped too.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Read a request body as raw bytes, aborting once it exceeds `limit` bytes.
|
||||
* Null when the cap is passed. For callers that need the exact bytes on the
|
||||
* wire, such as an HMAC over the body.
|
||||
*/
|
||||
export async function readBytesCapped(request: NextRequest, limit: number): Promise<Buffer | null> {
|
||||
const stream = request.body;
|
||||
if (!stream) return Buffer.alloc(0);
|
||||
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > limit) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks, bytes);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a request body as text, aborting once it exceeds `limit` bytes. */
|
||||
export async function readBodyCapped(request: NextRequest, limit: number): Promise<string | null> {
|
||||
const bytes = await readBytesCapped(request, limit);
|
||||
return bytes === null ? null : new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON body of at most `limit` bytes. Null for an oversized, empty or
|
||||
* unparseable body — the caller answers 400 either way.
|
||||
*/
|
||||
export async function readJsonCapped(request: NextRequest, limit: number): Promise<unknown> {
|
||||
const declared = Number(request.headers.get('content-length') ?? '');
|
||||
if (Number.isFinite(declared) && declared > limit) return null;
|
||||
try {
|
||||
const text = await readBodyCapped(request, limit);
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
38
lib/siteNav.server.ts
Normal file
38
lib/siteNav.server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Server-side loader for `siteNavLinks()` — fetches subpages, albums, pages
|
||||
* and journal entries. Server only (pulls in `fs` via the services).
|
||||
*
|
||||
* Memoised per request with React's `cache()`: the root layout's header nav
|
||||
* and the home page's hero nav both ask for it in one render, and the journal
|
||||
* listing is shared with the journal pages (`lib/journal.server.ts`).
|
||||
*/
|
||||
|
||||
import { cache } from 'react';
|
||||
import { immich } from './immich';
|
||||
import { getConfig } from './config';
|
||||
import { listJournalEntriesForRequest } from './journal.server';
|
||||
import { listPages } from './admin/pages-service';
|
||||
import { resolveMenu } from './pages';
|
||||
import { getServerDictionary } from './i18n/server';
|
||||
import { siteNavLinks, type SiteNavLink } from './siteNav';
|
||||
|
||||
export const loadSiteNav = cache(async function loadSiteNav(): Promise<SiteNavLink[]> {
|
||||
const [subpages, albums, journalEntries, pages] = await Promise.all([
|
||||
immich.getSubpages(),
|
||||
immich.getStandaloneAlbums(),
|
||||
listJournalEntriesForRequest().catch(() => []),
|
||||
listPages().catch(() => []),
|
||||
]);
|
||||
const config = getConfig();
|
||||
return siteNavLinks(
|
||||
{
|
||||
menu: resolveMenu(config.nav, subpages, pages),
|
||||
subpageAlbumCounts: new Map(subpages.map((sp) => [sp.slug, sp.albumCount])),
|
||||
albums,
|
||||
hasJournal: journalEntries.some((e) => !e.frontmatter.draft),
|
||||
aboutEnabled: config.aboutEnabled,
|
||||
map: config.map,
|
||||
},
|
||||
getServerDictionary(),
|
||||
);
|
||||
});
|
||||
52
lib/siteNav.ts
Normal file
52
lib/siteNav.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The site's navigation entries, in the one order the header and the home
|
||||
* page hero share (#696): subpages and content pages (gallery.yaml order),
|
||||
* standalone albums, Journal, About, Map.
|
||||
*
|
||||
* Client-safe: callers do the fetching and pass the results in.
|
||||
*/
|
||||
|
||||
import type { Dictionary } from './i18n';
|
||||
import type { MenuItem } from './pages';
|
||||
|
||||
export interface SiteNavLink {
|
||||
key: string;
|
||||
href: string;
|
||||
label: string;
|
||||
/** "3 albums" / "12 photos" — only the hero shows it, and only presets that want it. */
|
||||
count?: string;
|
||||
}
|
||||
|
||||
export interface SiteNavInput {
|
||||
/** Subpages and content pages, from `resolveMenu()`. */
|
||||
menu: readonly MenuItem[];
|
||||
/** Album count per subpage slug, for the hero's count line. */
|
||||
subpageAlbumCounts?: ReadonlyMap<string, number>;
|
||||
albums: ReadonlyArray<{ id: string; slug: string; albumName: string; assetCount?: number }>;
|
||||
/** At least one published (non-draft) journal entry. */
|
||||
hasJournal: boolean;
|
||||
aboutEnabled: boolean;
|
||||
map: boolean;
|
||||
}
|
||||
|
||||
export function siteNavLinks(input: SiteNavInput, t: Dictionary): SiteNavLink[] {
|
||||
const links: SiteNavLink[] = input.menu.map((item) => {
|
||||
const n = input.subpageAlbumCounts?.get(item.href.slice(1));
|
||||
return {
|
||||
...item,
|
||||
...(item.key.startsWith('sp-') && n !== undefined ? { count: t.common.albums(n) } : {}),
|
||||
};
|
||||
});
|
||||
for (const a of input.albums) {
|
||||
links.push({
|
||||
key: `al-${a.id}`,
|
||||
href: `/${a.slug}`,
|
||||
label: a.albumName,
|
||||
...(a.assetCount !== undefined ? { count: t.common.photos(a.assetCount) } : {}),
|
||||
});
|
||||
}
|
||||
if (input.hasJournal) links.push({ key: 'journal', href: '/journal', label: t.nav.journal });
|
||||
if (input.aboutEnabled) links.push({ key: 'about', href: '/about', label: t.nav.about });
|
||||
if (input.map) links.push({ key: 'map', href: '/map', label: t.nav.map });
|
||||
return links;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getConfig } from './config';
|
||||
import { immich } from './immich';
|
||||
import { isProtected, isSiteLocked } from './auth';
|
||||
import { listJournalEntries } from './admin/journal-service';
|
||||
import { listPages } from './admin/pages-service';
|
||||
import type { SiteShape, PublicAlbum } from './publicPages';
|
||||
|
||||
export async function buildSiteShape(): Promise<SiteShape> {
|
||||
@@ -26,6 +27,7 @@ export async function buildSiteShape(): Promise<SiteShape> {
|
||||
.map((album) => ({ id: album.id, slug: album.slug }));
|
||||
|
||||
const journal = await listJournalEntries().catch(() => []);
|
||||
const pages = await listPages().catch(() => []);
|
||||
|
||||
return {
|
||||
siteLocked: isSiteLocked(),
|
||||
@@ -40,6 +42,12 @@ export async function buildSiteShape(): Promise<SiteShape> {
|
||||
journal: journal.map((entry) => ({
|
||||
slug: entry.slug,
|
||||
draft: entry.frontmatter.draft === true,
|
||||
isProtected: !!entry.frontmatter.password,
|
||||
})),
|
||||
pages: pages.map((page) => ({
|
||||
slug: page.slug,
|
||||
draft: page.frontmatter.draft === true,
|
||||
isProtected: !!page.frontmatter.password,
|
||||
})),
|
||||
isAlbumProtected: (albumId) => isProtected(albumId, 'album'),
|
||||
aboutEnabled: config.aboutEnabled,
|
||||
|
||||
474
lib/zipArchive.ts
Normal file
474
lib/zipArchive.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* ZIP-of-originals streaming and the refusal page downloads answer with.
|
||||
*
|
||||
* Shared by the album archive route (/api/download/[album]/archive) and the
|
||||
* client proofing download (/api/proof/[token]/archive), which authorise
|
||||
* differently but must stream and refuse identically.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Readable } from 'node:stream';
|
||||
import archiver from 'archiver';
|
||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||
import { contentDisposition, safeDownloadName } from '@/lib/downloadName';
|
||||
import { getClientIp } from '@/lib/rate-limit';
|
||||
import { getDictionary } from '@/lib/i18n';
|
||||
import { getLocale, getServerDictionary } from '@/lib/i18n/server';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
import { accentForMode, resolveTheme } from '@/lib/config/theme';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/** Escape a value interpolated into the refusal page. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) =>
|
||||
char === '&'
|
||||
? '&'
|
||||
: char === '<'
|
||||
? '<'
|
||||
: char === '>'
|
||||
? '>'
|
||||
: char === '"'
|
||||
? '"'
|
||||
: ''',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal, rendered for whoever asked.
|
||||
*
|
||||
* A download begins as a navigation — the header link, or the proofing modal's
|
||||
* form POST — and only a success carries `Content-Disposition: attachment`. A
|
||||
* bare JSON body would therefore replace the gallery with
|
||||
* `{"error":"Too many requests"}` on a rate limit, which is the likely failure
|
||||
* (5 rpm, and the ZIP shows nothing until the first byte, so people click
|
||||
* again). Browsers get a page with a way back; callers asking for JSON keep it.
|
||||
*
|
||||
* The page speaks the site's language; the JSON stays English, like every other
|
||||
* API error.
|
||||
*/
|
||||
export function refusal(
|
||||
request: NextRequest,
|
||||
status: number,
|
||||
reason: RefusalReason,
|
||||
retryAfter?: number,
|
||||
): NextResponse {
|
||||
const headers: Record<string, string> = { 'Cache-Control': 'no-store' };
|
||||
if (retryAfter) headers['Retry-After'] = String(retryAfter);
|
||||
|
||||
if (!(request.headers.get('accept') ?? '').includes('text/html')) {
|
||||
return NextResponse.json({ error: getDictionary('en').download[reason] }, { status, headers });
|
||||
}
|
||||
|
||||
const t = getServerDictionary().download;
|
||||
headers['Content-Type'] = 'text/html; charset=utf-8';
|
||||
// Route handlers are outside proxy.ts, so this page gets no site policy. It
|
||||
// needs nothing but its own inline <style>: no script, no image, no form.
|
||||
headers['Content-Security-Policy'] =
|
||||
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'";
|
||||
return new NextResponse(
|
||||
`<!doctype html><html lang="${getLocale()}"><head><meta charset="utf-8">` +
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
||||
`<title>${escapeHtml(t.unavailableTitle)}</title><style>${refusalCss()}</style></head>` +
|
||||
`<body><main><h1>${escapeHtml(t.unavailableTitle)}</h1>` +
|
||||
`<p>${escapeHtml(t[reason])}</p>` +
|
||||
`<p><a href="${escapeHtml(backHref(request))}">${escapeHtml(t.back)}</a></p>` +
|
||||
'</main></body></html>',
|
||||
{ status, headers },
|
||||
);
|
||||
}
|
||||
|
||||
type Palette = { bg: string; text: string; muted: string };
|
||||
|
||||
/**
|
||||
* Each preset's `--bg-primary`, `--text-primary` and `--text-secondary` per
|
||||
* colour mode, as app/tokens.css defines them (`default` is its `:root` and
|
||||
* `[data-theme='light']`, which `studio` uses unchanged). The stylesheets are
|
||||
* bundled into hashed chunks a route handler cannot name, so the values are
|
||||
* repeated here; lib/__tests__/refusal-palette.test.ts fails when they drift.
|
||||
* The accent comes from the configured theme.
|
||||
*/
|
||||
export const REFUSAL_PALETTES: Record<string, { dark: Palette; light: Palette }> = {
|
||||
default: {
|
||||
dark: { bg: '#1a1a1a', text: '#f5f5f0', muted: '#a0a09a' },
|
||||
light: { bg: '#f5f5f0', text: '#1a1a18', muted: '#4a4a45' },
|
||||
},
|
||||
'studio-modern': {
|
||||
dark: { bg: '#121212', text: '#f4f4f2', muted: '#9c9c96' },
|
||||
light: { bg: '#fafaf8', text: '#161614', muted: '#5a5a55' },
|
||||
},
|
||||
minimal: {
|
||||
dark: { bg: '#000000', text: '#ffffff', muted: '#9e9e9e' },
|
||||
light: { bg: '#ffffff', text: '#000000', muted: '#475569' },
|
||||
},
|
||||
editorial: {
|
||||
dark: { bg: '#1a1714', text: '#ede8e0', muted: '#9a9086' },
|
||||
light: { bg: '#faf8f4', text: '#1a1714', muted: '#4a4540' },
|
||||
},
|
||||
classic: {
|
||||
dark: { bg: '#121210', text: '#f0ece4', muted: '#a09888' },
|
||||
light: { bg: '#fdfbf5', text: '#1a1810', muted: '#4a4535' },
|
||||
},
|
||||
noir: {
|
||||
dark: { bg: '#111014', text: '#f0ebe0', muted: '#998f80' },
|
||||
light: { bg: '#faf5ef', text: '#1a1610', muted: '#4a4538' },
|
||||
},
|
||||
monograph: {
|
||||
dark: { bg: '#151515', text: '#e8e8e8', muted: '#a5a5a5' },
|
||||
light: { bg: '#fafafa', text: '#111111', muted: '#475569' },
|
||||
},
|
||||
};
|
||||
|
||||
const HEX_COLOUR = /^#[0-9a-f]{3,8}$/i;
|
||||
|
||||
/**
|
||||
* The refusal page's stylesheet, in the site's colour mode and accent, so a
|
||||
* visitor bounced from a dark gallery does not land on a white system page.
|
||||
* `auto` follows the visitor's OS, as the site does before its script runs.
|
||||
*/
|
||||
function refusalCss(): string {
|
||||
const config = getConfigOrNull();
|
||||
const theme = config?.theme ?? resolveTheme();
|
||||
const mode = config?.colorMode ?? 'dark';
|
||||
const palette = REFUSAL_PALETTES[theme.preset] ?? REFUSAL_PALETTES.default;
|
||||
const vars = (m: 'dark' | 'light') => {
|
||||
const c = palette[m];
|
||||
const accent = accentForMode(theme, m);
|
||||
return (
|
||||
`color-scheme:${m};--bg:${c.bg};--text:${c.text};--muted:${c.muted};` +
|
||||
`--accent:${HEX_COLOUR.test(accent) ? accent : c.text}`
|
||||
);
|
||||
};
|
||||
const base =
|
||||
'body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;' +
|
||||
'padding:0 1.5rem;box-sizing:border-box;background:var(--bg);color:var(--text);' +
|
||||
'font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.6}' +
|
||||
'main{max-width:32rem}h1{font-size:1.25rem;font-weight:500;margin:0 0 .75rem}' +
|
||||
'p{margin:0 0 1rem;color:var(--muted)}' +
|
||||
'a{color:var(--text);text-decoration-color:var(--accent);' +
|
||||
'text-decoration-thickness:2px;text-underline-offset:.25em}';
|
||||
const root =
|
||||
mode === 'auto'
|
||||
? `:root{${vars('dark')}}@media (prefers-color-scheme: light){:root{${vars('light')}}}`
|
||||
: `:root{${vars(mode)}}`;
|
||||
return root + base;
|
||||
}
|
||||
|
||||
export type RefusalReason = 'notAvailable' | 'rateLimited' | 'immichUnavailable' | 'limitReached';
|
||||
|
||||
/**
|
||||
* Where the refusal page's link leads: the page the download was started from,
|
||||
* so a visitor lands back on the album rather than the home page. Only a
|
||||
* same-origin `Referer` is followed, and only its path — anything else would
|
||||
* turn this page into an open redirect with our name on it.
|
||||
*
|
||||
* "Same origin" is judged by the host the visitor addressed. It used to be
|
||||
* `request.nextUrl.origin`, which Next rebuilds from its own bind address —
|
||||
* `localhost` behind `-H 127.0.0.1` or a reverse proxy — so no real Referer
|
||||
* ever matched and the link always led home.
|
||||
*/
|
||||
function backHref(request: NextRequest): string {
|
||||
const referer = request.headers.get('referer');
|
||||
if (!referer) return '/';
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '/';
|
||||
if (!requestHosts(request).has(url.host.toLowerCase())) return '/';
|
||||
// `//evil.example/x` is a path to URL, but a host to the browser.
|
||||
if (url.pathname.startsWith('//')) return '/';
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosts this request may have been addressed to: the `Host` header and,
|
||||
* only with `TRUSTED_PROXY_HOPS` set (the same trust lib/rate-limit.ts needs),
|
||||
* the proxy's `X-Forwarded-Host`. A forged header can at most make a Referer
|
||||
* match; the link that results is still a path on this site.
|
||||
*/
|
||||
function requestHosts(request: NextRequest): Set<string> {
|
||||
const hosts = new Set<string>([request.nextUrl.host.toLowerCase()]);
|
||||
const host = request.headers.get('host');
|
||||
if (host) hosts.add(host.trim().toLowerCase());
|
||||
if (env.TRUSTED_PROXY_HOPS > 0) {
|
||||
for (const h of (request.headers.get('x-forwarded-host') ?? '').split(',')) {
|
||||
if (h.trim()) hosts.add(h.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique, ZIP-safe entry name. Originals can collide ("IMG_0001.jpg" from two
|
||||
* cards), and a ZIP with duplicate entry names is ambiguous to unzip.
|
||||
*/
|
||||
function uniqueEntryName(raw: string | undefined, used: Set<string>): string {
|
||||
const base = safeDownloadName(raw);
|
||||
let name = base;
|
||||
let n = 2;
|
||||
while (used.has(name)) {
|
||||
const dot = base.lastIndexOf('.');
|
||||
name = dot > 0 ? `${base.slice(0, dot)}-${n}${base.slice(dot)}` : `${base}-${n}`;
|
||||
n++;
|
||||
}
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one entry, resolving once it has been written (or the archive has been
|
||||
* torn down because the visitor left).
|
||||
*
|
||||
* archiver processes entries strictly one at a time — that is what keeps memory
|
||||
* flat — but `append()` only *queues* them. Firing every `append` up front (and
|
||||
* so opening every upstream request at once) is what made a large album's later
|
||||
* originals sit long enough to time out and truncate the ZIP. Awaiting the
|
||||
* `entry` event holds the loop until the previous file has finished, so only
|
||||
* one original is ever in flight. `close` resolves too, so an aborted download
|
||||
* stops the loop rather than hanging on a file nothing will read.
|
||||
*
|
||||
* On `close` and `error` the source is destroyed as well. archiver does not do
|
||||
* that for a queued or half-read entry, and the upstream body would otherwise
|
||||
* hold its socket out of undici's pool until GC finalises it (#635).
|
||||
*/
|
||||
function appendEntry(archive: archiver.Archiver, source: Readable, name: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
archive.off('entry', onEntry);
|
||||
archive.off('error', onError);
|
||||
archive.off('close', onClose);
|
||||
};
|
||||
const onEntry = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
// `close` fires when the response is consumed or aborted. Resolving on it
|
||||
// lets the loop see `archive.destroyed` and stop, rather than hanging on a
|
||||
// file nothing will read.
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
source.destroy();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
source.destroy();
|
||||
reject(error);
|
||||
};
|
||||
archive.once('entry', onEntry);
|
||||
archive.once('error', onError);
|
||||
archive.once('close', onClose);
|
||||
archive.append(source, { name });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives one client may have open at the same time, the album archive and
|
||||
* the proofing archive counted together.
|
||||
*
|
||||
* The 5-per-minute rate limit bounds how often an archive starts, not how long
|
||||
* it lives. Each open archive holds an Immich socket and a few MB of buffers
|
||||
* for as long as the client keeps its connection, so a client that reads
|
||||
* slowly could otherwise stack them up minute after minute. Two leaves room
|
||||
* for the proofing modal's selection and album ZIPs side by side.
|
||||
*/
|
||||
export const MAX_CONCURRENT_ARCHIVES = 2;
|
||||
|
||||
/** `Retry-After` for a refusal at the concurrency cap. */
|
||||
const ARCHIVE_SLOT_RETRY_AFTER = 30;
|
||||
|
||||
/**
|
||||
* How long an archive waits for the client to take the next bytes before it
|
||||
* is torn down, upstream Immich stream included.
|
||||
*
|
||||
* Counted from the last pull, and only while data is waiting for the client: a
|
||||
* slow download that keeps reading never trips it, and neither does a slow
|
||||
* Immich (the pull is then still pending, and the upstream request has its own
|
||||
* timeouts). It is needed because backpressure pauses undici's body timeout,
|
||||
* so a client that stopped reading kept the archive and its Immich socket for
|
||||
* as long as it held the TCP connection open.
|
||||
*/
|
||||
export const ARCHIVE_STALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Open archives per client IP. An entry is removed when it drops to 0. */
|
||||
const inFlight = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Take one of the client's archive slots. Returns the release function (safe
|
||||
* to call more than once), or `null` when the client is at the cap.
|
||||
*/
|
||||
function acquireArchiveSlot(ip: string): (() => void) | null {
|
||||
const current = inFlight.get(ip) ?? 0;
|
||||
if (current >= MAX_CONCURRENT_ARCHIVES) return null;
|
||||
inFlight.set(ip, current + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const remaining = (inFlight.get(ip) ?? 1) - 1;
|
||||
if (remaining > 0) inFlight.set(ip, remaining);
|
||||
else inFlight.delete(ip);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: archives currently holding a slot for `ip`. */
|
||||
export function __archivesInFlight(ip: string): number {
|
||||
return inFlight.get(ip) ?? 0;
|
||||
}
|
||||
|
||||
export type ArchiveStreamer = (albumName: string, assets: ImmichAsset[]) => Response;
|
||||
|
||||
/**
|
||||
* Run an archive route's handler inside one of the client's archive slots.
|
||||
*
|
||||
* The slot is taken before the handler runs, so before any Immich request, and
|
||||
* a client that already has `MAX_CONCURRENT_ARCHIVES` open gets a 429. The
|
||||
* handler receives a `stream` function to use instead of `streamArchive`: an
|
||||
* archive started through it owns the slot and gives it back when it ends,
|
||||
* fails, is cancelled or stalls. If the handler answers any other way (a
|
||||
* refusal, a throw), the slot is given back here.
|
||||
*
|
||||
* The client key is `getClientIp()`, so it honours `TRUSTED_PROXY_HOPS` like
|
||||
* the rate limiter.
|
||||
*/
|
||||
export async function withArchiveSlot(
|
||||
request: NextRequest,
|
||||
handler: (stream: ArchiveStreamer) => Promise<Response>,
|
||||
): Promise<Response> {
|
||||
const release = acquireArchiveSlot(getClientIp(request));
|
||||
if (!release) return refusal(request, 429, 'rateLimited', ARCHIVE_SLOT_RETRY_AFTER);
|
||||
|
||||
let handedOver = false;
|
||||
try {
|
||||
return await handler((albumName, assets) => {
|
||||
const response = streamArchive(albumName, assets, release);
|
||||
handedOver = true;
|
||||
return response;
|
||||
});
|
||||
} finally {
|
||||
if (!handedOver) release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `assets` as a ZIP of originals.
|
||||
*
|
||||
* archiver writes data descriptors, so entry sizes are never known up front and
|
||||
* memory stays flat no matter how large the album is. The loop pulls one
|
||||
* original at a time and stops as soon as the response is gone.
|
||||
*
|
||||
* `onDone` runs exactly once, when the archive has been read to the end,
|
||||
* failed, been cancelled or stalled; `withArchiveSlot` hands its slot release
|
||||
* in here.
|
||||
*/
|
||||
export function streamArchive(
|
||||
albumName: string,
|
||||
assets: ImmichAsset[],
|
||||
onDone: () => void = () => {},
|
||||
): NextResponse {
|
||||
const archive = archiver('zip', { store: true });
|
||||
archive.on('error', (err) => {
|
||||
// A visitor cancelling the download is not a failure worth a log line.
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error(`[Download] Archive stream failed:`, err);
|
||||
});
|
||||
|
||||
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
clearTimeout(stallTimer);
|
||||
if (done) return;
|
||||
done = true;
|
||||
onDone();
|
||||
};
|
||||
// Every way an archive ends passes through `close`: read to the end,
|
||||
// destroyed by an error, by a cancel, or by the stall timer below.
|
||||
archive.once('close', finish);
|
||||
|
||||
// A pull-based body rather than `Readable.toWeb()`: `pull` runs only once
|
||||
// the client has taken what was queued, which is the signal the stall timer
|
||||
// needs. The queue holds a single chunk; archiver's own buffer and
|
||||
// backpressure do the rest, as before.
|
||||
const chunks = archive[Symbol.asyncIterator]() as AsyncIterator<Buffer>;
|
||||
const body = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
async pull(controller) {
|
||||
clearTimeout(stallTimer);
|
||||
try {
|
||||
const next = await chunks.next();
|
||||
if (next.done) {
|
||||
controller.close();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
// A Buffer is a Uint8Array: handed on as is, not copied.
|
||||
controller.enqueue(next.value);
|
||||
} catch (err) {
|
||||
// Also reached when a cancel won the race: erroring a cancelled
|
||||
// stream is a no-op.
|
||||
controller.error(err);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (done) return;
|
||||
// The chunk is queued; the next move is the client's.
|
||||
stallTimer = setTimeout(() => {
|
||||
console.warn(
|
||||
`[Download] Archive closed: nothing read for ${ARCHIVE_STALL_TIMEOUT_MS / 1000}s`,
|
||||
);
|
||||
controller.error(new Error('Archive download stalled'));
|
||||
// Destroys the entry in flight and with it the upstream body
|
||||
// (appendEntry), and stops the fill loop.
|
||||
archive.destroy();
|
||||
finish();
|
||||
}, ARCHIVE_STALL_TIMEOUT_MS);
|
||||
stallTimer.unref?.();
|
||||
},
|
||||
cancel() {
|
||||
// The visitor left: tear the archive down, upstream body included.
|
||||
archive.destroy();
|
||||
finish();
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 1 },
|
||||
);
|
||||
|
||||
// Fill the archive in the background: the response has to go out first so the
|
||||
// browser starts reading, and each originals fetch is awaited in turn.
|
||||
void (async () => {
|
||||
try {
|
||||
const used = new Set<string>();
|
||||
for (const asset of assets) {
|
||||
// The visitor left (or the archive failed): stop pulling originals.
|
||||
if (archive.destroyed) break;
|
||||
const result = await immich.streamAsset(asset.id, 'original');
|
||||
if (!result) continue;
|
||||
if (archive.destroyed) {
|
||||
// Left while the headers were on their way: release the body unread.
|
||||
await result.stream.cancel();
|
||||
break;
|
||||
}
|
||||
const nodeStream = Readable.fromWeb(
|
||||
result.stream as unknown as import('node:stream/web').ReadableStream,
|
||||
);
|
||||
await appendEntry(archive, nodeStream, uniqueEntryName(asset.originalFileName, used));
|
||||
}
|
||||
if (!archive.destroyed) await archive.finalize();
|
||||
} catch (err) {
|
||||
archive.destroy(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
})();
|
||||
|
||||
const zipName = `${safeDownloadName(albumName, 'album')}.zip`;
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': contentDisposition(zipName),
|
||||
// Private: authorised per visitor, so a shared cache must not hand the
|
||||
// archive to the next one.
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user