release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -54,6 +54,19 @@ function forgeToken(signingKey: Buffer, payload: object): string {
return `${data}.${sig}`;
}
/**
* The production derivation (lib/admin/auth.ts's getSigningKey()), so a
* forged token is rejected only for the reason each test names — the `exp`
* or `role` check — rather than failing the HMAC compare first with a stale
* key. SHA-256 of `admin:${secret}:${password}` was that key before the
* session key moved to scrypt; verifyAdminToken has rejected it ever since,
* so a token forged that way never reached the checks below the signature
* compare at all (#637).
*/
function signingKey(secret: string, password: string): Buffer {
return crypto.scryptSync(password, `folio-admin-session:${secret}`, 32);
}
beforeEach(() => {
process.env.__TEST_AUTH_SECRET = AUTH_SECRET;
process.env.__TEST_ADMIN_PASSWORD = ADMIN_PASSWORD;
@@ -135,20 +148,14 @@ describe('admin session tokens', () => {
it('rejects an expired token', async () => {
const { verifyAdminToken } = await loadAuth();
const key = crypto
.createHash('sha256')
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
.digest();
const key = signingKey(AUTH_SECRET, ADMIN_PASSWORD);
const expired = forgeToken(key, { role: 'admin', iat: 0, exp: Date.now() - 1000 });
expect(verifyAdminToken(expired)).toBe(false);
});
it('rejects a validly signed token whose role is not admin', async () => {
const { verifyAdminToken } = await loadAuth();
const key = crypto
.createHash('sha256')
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
.digest();
const key = signingKey(AUTH_SECRET, ADMIN_PASSWORD);
const wrongRole = forgeToken(key, { role: 'viewer', iat: 0, exp: Date.now() + 60_000 });
expect(verifyAdminToken(wrongRole)).toBe(false);
});

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, readdir, readFile, rm, writeFile, mkdir, stat } from 'fs/promises';
import { atomicWrite } from '../atomicWrite';
/**
* This is the primitive every piece of user-supplied content is written
* through — gallery.yaml, settings.yaml, journal entries, about.md, the
* favicon. It existed as six hand-written copies that had already drifted in
* their temp-file naming and their encoding, so it is worth pinning properly.
*/
let root: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-atomic-'));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const leftovers = async () => (await readdir(root)).filter((f) => f.endsWith('.tmp'));
describe('atomicWrite', () => {
it('writes a new file and leaves no temp file behind', async () => {
const target = path.join(root, 'settings.yaml');
await atomicWrite(target, 'title: Folio\n');
expect(await readFile(target, 'utf8')).toBe('title: Folio\n');
expect(await leftovers()).toEqual([]);
});
it('replaces an existing file', async () => {
const target = path.join(root, 'gallery.yaml');
await writeFile(target, 'old');
await atomicWrite(target, 'new');
expect(await readFile(target, 'utf8')).toBe('new');
});
it('writes a Buffer verbatim', async () => {
const target = path.join(root, 'favicon.ico');
// A byte that is not valid UTF-8, so an accidental string conversion shows.
const bytes = Buffer.from([0x00, 0x01, 0xff, 0xfe]);
await atomicWrite(target, bytes);
expect(Buffer.compare(await readFile(target), bytes)).toBe(0);
});
it('round-trips non-ASCII content as utf8', async () => {
const target = path.join(root, 'about.md');
const content = '# Über mich\n\nFotografie aus Brandenburg — 日本語 too.\n';
await atomicWrite(target, content);
expect(await readFile(target, 'utf8')).toBe(content);
});
it('gives concurrent writes to the same path distinct temp files', async () => {
const target = path.join(root, 'contended.yaml');
// The failure this guards against is a shared temp name: one writer's
// bytes published under the other's, or a truncated mix of both.
await Promise.all(Array.from({ length: 25 }, (_, i) => atomicWrite(target, `writer-${i}\n`)));
const survivor = await readFile(target, 'utf8');
expect(survivor).toMatch(/^writer-\d+\n$/);
expect(await leftovers()).toEqual([]);
});
it('cleans up the temp file and rethrows when the write cannot land', async () => {
// A directory at the target path makes rename fail after the temp file was
// written successfully — the exact window the cleanup exists for.
const target = path.join(root, 'occupied');
await mkdir(target);
await expect(atomicWrite(target, 'content')).rejects.toThrow();
expect(await leftovers()).toEqual([]);
});
it('rejects when the directory does not exist, without creating it', async () => {
const target = path.join(root, 'missing-dir', 'file.yaml');
await expect(atomicWrite(target, 'content')).rejects.toThrow();
expect(await readdir(root)).toEqual([]);
});
/**
* A secret file (install.json's API key and auth secret) used to be
* written with the default mode and `chmod`ed to 0600 afterwards — briefly
* world-readable between the two. `mode` applies to the temp file before
* the rename, so the file is never created with the wrong permissions at
* all (GHSA-w293-x8pc-j4cv). Skipped on Windows, where POSIX mode bits do
* not apply.
*/
const describeMode = process.platform === 'win32' ? describe.skip : describe;
describeMode('mode option', () => {
it('creates a new file with the given mode', async () => {
const target = path.join(root, 'install.json');
await atomicWrite(target, '{}', { mode: 0o600 });
const mode = (await stat(target)).mode & 0o777;
expect(mode).toBe(0o600);
});
it('defaults to the process umask when no mode is given', async () => {
const target = path.join(root, 'gallery.yaml');
await atomicWrite(target, 'albums: []\n');
const mode = (await stat(target)).mode & 0o777;
expect(mode & 0o600).toBe(0o600); // owner read/write, at minimum
});
});
});

View File

@@ -43,6 +43,7 @@ import {
authenticate,
isAuthenticated,
isAlbumReachable,
withoutLockedAlbums,
findSubpageBySlug,
} from '@/lib/auth';
@@ -258,3 +259,30 @@ describe('isAlbumReachable', () => {
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
});
});
/**
* The essay layout puts the photographs of several albums on one page and
* never passes through an album's own gate, so it has to leave out the ones
* that are still locked.
*/
describe('withoutLockedAlbums', () => {
const OPEN = { id: '00000000-0000-0000-0000-000000000002' };
const LOCKED = { id: '00000000-0000-0000-0000-000000000005' };
it('keeps an album that has no password of its own', () => {
expect(withoutLockedAlbums([OPEN], () => undefined)).toEqual([OPEN]);
});
it('leaves out an album whose password has not been given', () => {
expect(withoutLockedAlbums([OPEN, LOCKED], () => undefined)).toEqual([OPEN]);
});
it('brings it back once it has been unlocked, in the original order', async () => {
const header = (await authenticate(LOCKED.id, 'albumpass', 'album'))!;
const [name, rest] = header.split('=');
const token = rest.split(';')[0];
const getCookie = (asked: string) => (asked === name ? token : undefined);
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
});
});

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readdir, rm, writeFile } from 'fs/promises';
/**
* Pre-restore snapshots were exempt from pruning while still being listed, so
* every restore added a row that was never removed. After a dozen restores the
* backup modal showed nothing but pre-restore entries and the ordinary saves
* had dropped off the end.
*
* They are still counted separately from saves — ten saves must not push out
* the one snapshot that undoes a restore — but each kind is now capped.
*
* The service resolves content/ from process.cwd() at import time, so each test
* points cwd at a fresh temp directory and re-imports it.
*/
let root: string;
let contentDir: string;
let backupDir: string;
let service: typeof import('../admin/yaml-service');
const stamp = (n: number) => `2026-05-${String(n).padStart(2, '0')}T12-00-00-000Z`;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-prune-'));
contentDir = path.join(root, 'content');
backupDir = path.join(contentDir, '.backups');
await mkdir(backupDir, { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
service = await import('../admin/yaml-service');
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const backups = async (kind?: 'pre-restore') => {
const files = (await readdir(backupDir)).filter((f) => f.startsWith('settings.yaml'));
return kind
? files.filter((f) => f.includes(kind))
: files.filter((f) => !f.includes('pre-restore'));
};
describe('backup pruning', () => {
it('caps pre-restore snapshots instead of letting them accumulate', async () => {
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
for (let i = 1; i <= 14; i++) {
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
}
await service.writeSettingsYaml({ title: 'Folio' });
expect(await backups('pre-restore')).toHaveLength(10);
});
it('keeps the newest pre-restore snapshots, not the oldest', async () => {
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
for (let i = 1; i <= 14; i++) {
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
}
await service.writeSettingsYaml({ title: 'Folio' });
const kept = await backups('pre-restore');
expect(kept).toContain(`settings.yaml.${stamp(14)}.pre-restore.bak`);
expect(kept).not.toContain(`settings.yaml.${stamp(1)}.pre-restore.bak`);
});
it('does not let saves push out the snapshot that undoes a restore', async () => {
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
await writeFile(path.join(backupDir, `settings.yaml.${stamp(1)}.pre-restore.bak`), 'the undo');
for (let i = 2; i <= 13; i++) {
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.bak`), 'a save');
}
await service.writeSettingsYaml({ title: 'Folio' });
// Saves are capped, but the lone pre-restore snapshot survives them.
expect(await backups()).toHaveLength(10);
expect(await backups('pre-restore')).toEqual([`settings.yaml.${stamp(1)}.pre-restore.bak`]);
});
it('prunes on restore, without waiting for the next save', async () => {
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: current\n');
await writeFile(path.join(backupDir, `settings.yaml.${stamp(1)}.bak`), 'title: old\n');
for (let i = 2; i <= 13; i++) {
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
}
await service.restoreBackup(`settings.yaml.${stamp(1)}.bak`);
// The restore adds one of its own, and the cap holds all the same.
expect(await backups('pre-restore')).toHaveLength(10);
});
it('leaves the other file backups alone', async () => {
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
for (let i = 1; i <= 14; i++) {
await writeFile(path.join(backupDir, `gallery.yaml.${stamp(i)}.pre-restore.bak`), 'old');
}
await service.writeSettingsYaml({ title: 'Folio' });
const gallery = (await readdir(backupDir)).filter((f) => f.startsWith('gallery.yaml'));
expect(gallery).toHaveLength(14);
});
});

View File

@@ -61,17 +61,22 @@ describe('restoreBackup rejects anything that is not a backup it produced', () =
describe('restoreBackup accepts the names it writes', () => {
beforeEach(() => vi.clearAllMocks());
// The restore itself goes through atomicWrite (writeFile the temp file,
// then rename it onto the target) rather than fs.copyFile, so a failure
// partway through cannot truncate the live file (#630). fs.copyFile is
// still used, but only for the pre-restore safety snapshot.
it('restores a normal backup', async () => {
await expect(
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
).resolves.toBeUndefined();
expect(fs.copyFile).toHaveBeenCalled();
expect(fs.copyFile).toHaveBeenCalled(); // pre-restore snapshot
expect(fs.rename).toHaveBeenCalled(); // atomic restore write
});
it('restores an about.md backup onto content/about.md', async () => {
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
const calls = vi.mocked(fs.copyFile).mock.calls;
const calls = vi.mocked(fs.rename).mock.calls;
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
});
@@ -84,8 +89,38 @@ describe('restoreBackup accepts the names it writes', () => {
it('derives the destination from the matched name, not a substring search', async () => {
await restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.bak');
// Last copyFile is backup → target; the first is the pre-restore snapshot.
const calls = vi.mocked(fs.copyFile).mock.calls;
const calls = vi.mocked(fs.rename).mock.calls;
expect(String(calls[calls.length - 1][1])).toMatch(/content\/settings\.yaml$/);
});
it('reads the backup before touching the pre-restore snapshot or the live file', async () => {
await restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak');
const readOrder = vi.mocked(fs.readFile).mock.invocationCallOrder[0];
const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder[0];
expect(readOrder).toBeLessThan(copyOrder);
});
it('aborts without touching the live file when the safety snapshot fails for a reason other than ENOENT', async () => {
vi.mocked(fs.copyFile).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
await expect(restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak')).rejects.toThrow(
'EACCES',
);
expect(fs.writeFile).not.toHaveBeenCalled();
expect(fs.rename).not.toHaveBeenCalled();
});
it('proceeds when the safety snapshot fails only because there is no current file yet', async () => {
vi.mocked(fs.copyFile).mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
await expect(
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
).resolves.toBeUndefined();
expect(fs.rename).toHaveBeenCalled();
});
});

View File

@@ -10,6 +10,7 @@ vi.mock('@/lib/env', () => ({
CACHE_TTL: 300,
RATE_LIMIT_RPM: 120,
},
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
}));
vi.mock('@/lib/secret', () => ({

View File

@@ -11,6 +11,7 @@ vi.mock('@/lib/env', () => ({
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
}));
vi.mock('@/lib/secret', () => ({

View File

@@ -11,6 +11,7 @@ vi.mock('@/lib/env', () => ({
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
}));
vi.mock('@/lib/secret', () => ({

View File

@@ -15,6 +15,7 @@ vi.mock('@/lib/env', () => ({
import {
slugify,
albumSlug,
findAlbumSlugCollisions,
normalizeSlug,
buildSubpageGrid,
sanitizeNavLinks,
@@ -125,6 +126,55 @@ describe('albumSlug', () => {
});
});
describe('findAlbumSlugCollisions', () => {
it('groups albums whose names slugify alike', () => {
const collisions = findAlbumSlugCollisions([
{ id: 'a', name: 'Japan Trip' },
{ id: 'b', name: 'Japan-Trip' },
{ id: 'c', name: 'Iceland' },
]);
expect(collisions).toEqual([
{
slug: 'japan-trip',
albums: [
{ id: 'a', name: 'Japan Trip' },
{ id: 'b', name: 'Japan-Trip' },
],
},
]);
});
it('reports nothing when every slug is distinct', () => {
expect(
findAlbumSlugCollisions([
{ id: 'a', name: 'Japan Trip' },
{ id: 'b', name: 'Iceland' },
]),
).toEqual([]);
});
// albumSlug() falls back to the id for these, which is unique by
// construction — two emoji-only albums do not actually collide.
it('does not flag two names that both slugify to nothing', () => {
expect(
findAlbumSlugCollisions([
{ id: 'a', name: '🎉' },
{ id: 'b', name: '🎊' },
]),
).toEqual([]);
});
it('groups three or more colliding names together', () => {
const collisions = findAlbumSlugCollisions([
{ id: 'a', name: 'Trip' },
{ id: 'b', name: 'TRIP' },
{ id: 'c', name: 'trip' },
]);
expect(collisions).toHaveLength(1);
expect(collisions[0].albums).toHaveLength(3);
});
});
describe('normalizeSlug', () => {
// Next hands catch-all route segments over percent-encoded, so this is the
// half of #522 that fixing slugify() alone did not cure.

View File

@@ -252,3 +252,73 @@ describe('deriveGallery accepts valid structures', () => {
expect(result.subpages[1].enabled).toBe(false);
});
});
/**
* `slugify` folds diacritics and punctuation, so distinct names collide
* easily — "Portfolio 2024" and "Portfolio-2024" both become
* "portfolio-2024". Every consumer that resolves a slug takes the first
* match, so the second entry was silently unreachable and its settings
* resolved from the first (#632).
*/
describe('deriveGallery rejects colliding slugs', () => {
it('rejects two subpages that slugify alike, naming both', () => {
expect(() =>
deriveGallery({
subpages: [
{ name: 'Portfolio 2024', albums: [A] },
{ name: 'Portfolio-2024', albums: [B] },
],
} as unknown as GalleryYaml),
).toThrow(/"Portfolio 2024".*"Portfolio-2024"|"Portfolio-2024".*"Portfolio 2024"/);
});
it('accepts subpages whose slugs are genuinely distinct', () => {
expect(() =>
deriveGallery({
subpages: [
{ name: 'Portfolio 2024', albums: [A] },
{ name: 'Portfolio 2025', albums: [B] },
],
} as unknown as GalleryYaml),
).not.toThrow();
});
it('rejects two standalone albums whose title overrides slugify alike', () => {
expect(() =>
deriveGallery({
albums: [{ [A]: { title: 'Japan Trip' } }, { [B]: { title: 'Japan-Trip' } }],
} as unknown as GalleryYaml),
).toThrow(/"Japan Trip".*"Japan-Trip"|"Japan-Trip".*"Japan Trip"/);
});
it('rejects two album overrides within the same subpage that slugify alike', () => {
expect(() =>
deriveGallery({
subpages: [
{
name: 'Trips',
albums: [{ [A]: { title: 'Japan Trip' } }, { [B]: { title: 'Japan-Trip' } }],
},
],
} as unknown as GalleryYaml),
).toThrow(/subpage "Trips"/);
});
it('does not flag the same collision across two different subpages', () => {
// Different URL prefixes — /trips-a/japan-trip and /trips-b/japan-trip do
// not collide with each other, only within one subpage's own route.
expect(() =>
deriveGallery({
subpages: [
{ name: 'Trips A', albums: [{ [A]: { title: 'Japan Trip' } }] },
{ name: 'Trips B', albums: [{ [B]: { title: 'Japan Trip' } }] },
],
} as unknown as GalleryYaml),
).not.toThrow();
});
it('does not flag an album with no title override against another with no override', () => {
// Neither album's real name is known without Immich — left to the doctor.
expect(() => deriveGallery({ albums: [A, B] } as unknown as GalleryYaml)).not.toThrow();
});
});

View File

@@ -4,6 +4,7 @@ import {
checkProxyHops,
countForwardedHops,
checkAlbumIds,
checkAlbumSlugCollisions,
checkAlbumsShared,
checkPasswords,
checkWritable,
@@ -11,6 +12,14 @@ import {
worstLevel,
} from '../admin/doctor';
/** A stand-in for lib/config/schema.ts's slugify(), good enough to test the
* grouping logic without doctor.ts importing anything (see its own comment). */
const testSlug = (name: string) =>
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
describe('checkAuthSecret', () => {
it('accepts a long secret', () => {
expect(checkAuthSecret('a'.repeat(64)).level).toBe('ok');
@@ -94,6 +103,19 @@ describe('checkProxyHops', () => {
it('accepts one configured hop against one corroborated entry', () => {
expect(checkProxyHops(1, 1, true).level).toBe('ok');
});
/**
* Cloudflare (1 hop) in front of nginx (1 more, 2 total) with the value set
* to 1 used to report "matches the observed chain" — the client IP was
* actually read from the Cloudflare edge address, so every visitor behind
* that PoP shared one rate-limit bucket (#629).
*/
it('warns, not passes, when fewer hops are configured than arrived', () => {
const f = checkProxyHops(1, 2, true);
expect(f.level).toBe('warn');
expect(f.title).toContain('1');
expect(f.title).toContain('2');
});
});
describe('checkAlbumIds', () => {
@@ -142,6 +164,71 @@ describe('checkAlbumsShared', () => {
});
});
describe('checkAlbumSlugCollisions', () => {
const known = [
{ id: 'a', albumName: 'Japan Trip' },
{ id: 'b', albumName: 'Japan-Trip' },
{ id: 'c', albumName: 'Iceland' },
];
it('is quiet when nothing collides', () => {
const f = checkAlbumSlugCollisions(
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
{},
known,
testSlug,
);
expect(f.level).toBe('ok');
});
it('flags two albums in the same group whose Immich names slugify alike', () => {
const f = checkAlbumSlugCollisions(
[{ context: 'gallery.yaml albums', albumIds: ['a', 'b'] }],
{},
known,
testSlug,
);
expect(f.level).toBe('error');
expect(f.detail).toContain('Japan Trip');
expect(f.detail).toContain('Japan-Trip');
expect(f.albumIds).toEqual(['a', 'b']);
});
it('does not flag the same collision across two different groups', () => {
// /trips-a/japan-trip and /trips-b/japan-trip are different URLs.
const f = checkAlbumSlugCollisions(
[
{ context: 'subpage "Trips A"', albumIds: ['a'] },
{ context: 'subpage "Trips B"', albumIds: ['b'] },
],
{},
known,
testSlug,
);
expect(f.level).toBe('ok');
});
it('prefers a title override over the Immich name', () => {
const f = checkAlbumSlugCollisions(
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
{ c: 'Japan Trip' }, // overrides "Iceland" — now collides with "a"
known,
testSlug,
);
expect(f.level).toBe('error');
});
it('says nothing about an id Immich does not know and has no override', () => {
const f = checkAlbumSlugCollisions(
[{ context: 'gallery.yaml albums', albumIds: ['a', 'ghost'] }],
{},
known,
testSlug,
);
expect(f.level).toBe('ok');
});
});
describe('checkPasswords', () => {
it('passes when everything is hashed', () => {
expect(checkPasswords([{ label: 'Site', value: 'scrypt:aa:bb' }]).level).toBe('ok');

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
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, deriveGallery } from '@/lib/config';
import { loadYaml } from '@/lib/config/parser';
import type { GalleryYaml } from '@/lib/config/schema';
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
function withSettings(settings: unknown) {
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
if (filename === 'settings.yaml') return settings;
return null;
});
}
afterEach(() => {
invalidateConfigCache();
vi.clearAllMocks();
});
/**
* `--grid-columns` is emitted unclamped in app/[...path]/page.tsx, so a bad
* `columns` reaches `repeat()` invalid and collapses the whole grid to one
* column with no error anywhere (#633). The cover grid already clamped
* (buildCoverGridVars); the site-wide photo grid did not.
*/
describe('site-wide grid.columns / grid.gap clamping (#633)', () => {
it('clamps a negative columns count to the minimum', () => {
withSettings({ grid: { columns: -1 } });
expect(getConfig().grid.columns).toBe(1);
});
it('clamps an out-of-range columns count to the maximum', () => {
withSettings({ grid: { columns: 99 } });
expect(getConfig().grid.columns).toBe(6);
});
it('falls back to the default when columns is not a number', () => {
withSettings({ grid: { columns: 'three' as unknown as number } });
expect(getConfig().grid.columns).toBe(1); // clamp()'s min, not silently NaN
});
it('keeps a legitimate columns value', () => {
withSettings({ grid: { columns: 4 } });
expect(getConfig().grid.columns).toBe(4);
});
it('defaults columns to 3 when unset', () => {
withSettings({});
expect(getConfig().grid.columns).toBe(3);
});
it('clamps gap the same way', () => {
withSettings({ grid: { gap: -5 } });
expect(getConfig().grid.gap).toBe(0);
withSettings({ grid: { gap: 9999 } });
invalidateConfigCache();
expect(getConfig().grid.gap).toBe(48);
});
});
/**
* Same guard for a subpage's or an album's own grid override — the merged
* value (site-wide default overridden by this) is what actually reaches
* `--grid-columns`.
*/
describe('per-subpage grid override clamping (#633)', () => {
it('clamps a negative subpage columns override', () => {
const gallery = {
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: -1 } }],
} as unknown as GalleryYaml;
const derived = deriveGallery(gallery);
expect(derived.subpages[0].grid?.columns).toBe(1);
});
it('clamps an out-of-range subpage columns override', () => {
const gallery = {
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: 40 } }],
} as unknown as GalleryYaml;
const derived = deriveGallery(gallery);
expect(derived.subpages[0].grid?.columns).toBe(6);
});
it('keeps a legitimate subpage columns override', () => {
const gallery = {
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: 2 } }],
} as unknown as GalleryYaml;
const derived = deriveGallery(gallery);
expect(derived.subpages[0].grid?.columns).toBe(2);
});
});

View File

@@ -15,14 +15,14 @@ describe('resolveImageSize', () => {
expect(resolveImageSize(null, null)).toBe('preview');
});
it('honours an explicit size on its own', () => {
it('honours an explicit size on its own, up to the preview ceiling', () => {
expect(resolveImageSize('thumbnail', null)).toBe('thumbnail');
expect(resolveImageSize('original', null)).toBe('original');
expect(resolveImageSize('preview', null)).toBe('preview');
});
it('falls back to the width-derived tier when no size is given', () => {
expect(resolveImageSize(null, '128')).toBe('thumbnail');
expect(resolveImageSize(null, '3840')).toBe('original');
expect(resolveImageSize(null, '1000')).toBe('preview');
});
it('ignores an unrecognised size rather than trusting it', () => {
@@ -57,4 +57,33 @@ describe('resolveImageSize', () => {
expect(resolveImageSize('preview', '640')).toBe('preview');
});
});
/**
* `/api/image` treats the opaque token as the whole capability check —
* holding one means you saw the page it was rendered on. That is right for
* a preview, not for the un-downsampled original: only `/api/download`
* verifies the album allowlist, `download: true` and every password gate.
* `?size=original` or a large `?w=` alone used to reach `original`
* unopposed, since the smaller()-of-both-parameters check only ran when
* both were present (GHSA-36m4-p39x-9wx8).
*/
describe('never returns original, regardless of what is asked for', () => {
it('caps an explicit ?size=original to preview', () => {
expect(resolveImageSize('original', null)).toBe('preview');
});
it('caps a large ?w= alone to preview', () => {
expect(resolveImageSize(null, '3840')).toBe('preview');
expect(resolveImageSize(null, '1920')).toBe('preview');
});
it('caps the combination of size=original and a large width', () => {
expect(resolveImageSize('original', '3840')).toBe('preview');
});
it('still narrows to thumbnail when the width says so', () => {
// The cap is a ceiling, not a floor — a small width may still win.
expect(resolveImageSize('original', '128')).toBe('thumbnail');
});
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { immich, ImmichUnavailableError } from '../immich';
import * as config from '../config';
import { cache } from '../cache';
@@ -8,6 +8,8 @@ import type { AlbumSortMode } from '../albumSort';
// able to change it between calls without rebuilding the whole mock.
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
// Same for the subpages: the route tests below add one and take it away again.
const subpages: { slug: string; albumIds: string[]; enabled?: boolean }[] = [];
// Mock the config by wrapping it in a factory
vi.mock('../config', async () => {
@@ -18,8 +20,10 @@ vi.mock('../config', async () => {
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: ['album-1', 'album-2'],
standaloneAlbums: ['album-2', 'album-1'],
subpages: [],
standaloneAlbums: ['album-2', 'album-1'].filter(
(id) => !subpages.some((sp) => sp.albumIds.includes(id)),
),
subpages,
albumOverrides: { 'album-1': 'Override Name' },
albumDescriptions: {},
albumSortModes,
@@ -267,6 +271,53 @@ describe('ImmichClient', () => {
const result = await immich.streamVideo('asset-1', 'bytes=0-1023');
expect(result?.status).toBe(206);
});
/**
* Neither error branch reads the body. Under undici an unconsumed one
* keeps its socket out of the pool until GC finalises it, so a proxy
* answering every request with an error accumulates orphaned sockets for
* as long as the fault lasts (#635).
*/
const streamResWithBody = (status: number) => {
const cancel = vi.fn();
return {
res: {
ok: status >= 200 && status < 300,
status,
body: { cancel },
headers: { get: () => null },
},
cancel,
};
};
it('streamAsset cancels the body on the "gone" path', async () => {
const { res, cancel } = streamResWithBody(404);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamAsset('asset-1')).resolves.toBeNull();
expect(cancel).toHaveBeenCalledOnce();
});
it('streamAsset cancels the body on the "outage" path', async () => {
const { res, cancel } = streamResWithBody(500);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
expect(cancel).toHaveBeenCalledOnce();
});
it('streamVideo cancels the body on the "gone" path', async () => {
const { res, cancel } = streamResWithBody(404);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamVideo('asset-1')).resolves.toBeNull();
expect(cancel).toHaveBeenCalledOnce();
});
it('streamVideo cancels the body on the "outage" path', async () => {
const { res, cancel } = streamResWithBody(502);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamVideo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
expect(cancel).toHaveBeenCalledOnce();
});
});
// Measured before the fix: 5 sequential lookups of a missing asset produced 5
@@ -438,6 +489,66 @@ describe('ImmichClient', () => {
});
});
/**
* config.albums is the union of standalone and subpage albums. A slug has to
* be looked up among the albums of the route it arrived on, or a top-level
* URL answers for an album whose only route is a subpage — past that
* subpage's password.
*/
describe('getAlbumBySlug() searches the route, not the allowlist', () => {
beforeEach(() => {
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/search/metadata')) {
return json({ assets: { items: [], nextPage: null, total: 0 } });
}
const one = /\/albums\/(album-[12])/.exec(url);
if (one) {
const albumName = one[1] === 'album-1' ? 'Client Preview' : 'Open Work';
return json({ id: one[1], albumName, assetCount: 0, assets: [], order: 'desc' });
}
return json([
{ id: 'album-1', albumName: 'Client Preview', assetCount: 0 },
{ id: 'album-2', albumName: 'Open Work', assetCount: 0 },
]);
});
});
afterEach(() => {
subpages.length = 0;
});
it('finds a standalone album at the top level', async () => {
const album = await immich.getAlbumBySlug('open-work');
expect(album?.id).toBe('album-2');
});
it('does not find a subpage album at the top level', async () => {
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
// album-1 carries the 'Override Name' override, so that is its slug.
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
expect((await immich.getAlbumBySlug('override-name', 'handover'))?.id).toBe('album-1');
});
it('does not find anything through a disabled subpage', async () => {
subpages.push({ slug: 'handover', albumIds: ['album-1'], enabled: false });
expect(await immich.getAlbumBySlug('override-name', 'handover')).toBeNull();
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
});
it('does not find a standalone album through a subpage that does not list it', async () => {
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
expect(await immich.getAlbumBySlug('open-work', 'handover')).toBeNull();
});
});
describe('getStandaloneAlbums()', () => {
it('orders albums by gallery.yaml order, not Immich API order', async () => {
// The Immich API returns albums in its own (creation/update) order;
@@ -760,6 +871,62 @@ describe('stale fallback when Immich is unavailable', () => {
await expect(immich.getAlbums()).rejects.toThrow(ImmichUnavailableError);
});
/**
* getAlbums(true) used to delete the cache entry before attempting the
* fetch. A request with forceFresh that then failed left every later
* visitor — not just the one who asked for a refresh — with nothing to
* fall back to, for as long as the outage lasted (GHSA-w293-x8pc-j4cv).
*/
it('forceFresh does not delete the entry a failed refresh could have fallen back to', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{
id: 'album-1',
albumName: 'Original',
description: '',
albumThumbnailAssetId: null,
assetCount: 1,
assets: [],
createdAt: '',
updatedAt: '',
order: 'desc',
},
],
});
expect(await immich.getAlbums()).toHaveLength(1);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const refreshed = await immich.getAlbums(true);
expect(refreshed).toHaveLength(1);
expect(refreshed[0].id).toBe('album-1');
});
it('getAlbum(id, true) has the same guarantee as getAlbums(true)', async () => {
// GET /albums/:id and POST /search/metadata run concurrently (Promise.all
// in loadAlbum), so they must be routed by URL rather than queued in call
// order — same pattern as the getAlbum() describe block below.
mockFetch.mockImplementation(async (url: string) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () =>
url.includes('/search/metadata')
? { assets: { items: [], nextPage: null, total: 0 } }
: { id: 'album-1', albumName: 'Original', assetCount: 0, assets: [], order: 'desc' },
}));
expect(await immich.getAlbum('album-1')).not.toBeNull();
mockFetch.mockImplementation(async () => {
throw new Error('ECONNREFUSED');
});
const refreshed = await immich.getAlbum('album-1', true);
expect(refreshed).not.toBeNull();
expect(refreshed?.id).toBe('album-1');
});
it('does not swallow a definitive 404 as an outage', async () => {
// A missing album must keep reporting missing, not fall back to stale data.
mockFetch.mockResolvedValue({
@@ -775,4 +942,44 @@ describe('stale fallback when Immich is unavailable', () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(immich.getAlbum('not-allowed')).resolves.toBeNull();
});
// #626: a cached 404 used to come back from the stale fallback as the MISSING
// sentinel itself — a truthy object with no fields. Every `if (!asset)` guard
// passed on it and the page answered 500 for the length of the outage.
const notFound = {
ok: false,
status: 404,
statusText: 'Not Found',
headers: { get: () => 'application/json' },
};
it('answers a stale "missing" asset with null during an outage', async () => {
mockFetch.mockResolvedValue(notFound);
await expect(immich.getAssetInfo('deleted-hero')).resolves.toBeNull();
vi.useFakeTimers();
vi.advanceTimersByTime(60_001); // past cacheTtl, inside the stale window
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await immich.getAssetInfo('deleted-hero');
vi.useRealTimers();
// toBeNull, not toBeFalsy: the bug was a truthy object, so the precise
// assertion is the one that would have failed.
expect(result).toBeNull();
});
it('answers a stale "missing" album with null during an outage', async () => {
mockFetch.mockResolvedValue(notFound);
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
vi.useFakeTimers();
vi.advanceTimersByTime(60_001);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await immich.getAlbum('album-1');
vi.useRealTimers();
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.mock is hoisted above the file, so the stubs have to be too.
const { getStale, set } = vi.hoisted(() => ({ getStale: vi.fn(), set: vi.fn() }));
vi.mock('../cache', () => ({ cache: { getStale, set } }));
import { MISSING, cacheSet, staleOrMissing, staleOrThrow } from '../immichCache';
import { ImmichUnavailableError } from '../immichTransport';
/**
* This policy decides what visitors see while Immich is down, and it used to be
* reachable only through HTTP mocks against the client (#610). These are the
* tests that became possible once it stood on its own.
*/
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('staleOrThrow', () => {
it('serves the last known good answer when Immich is unavailable', () => {
getStale.mockReturnValue([{ id: 'album-1' }]);
const result = staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums');
expect(result).toEqual([{ id: 'album-1' }]);
});
it('rethrows when nothing usable is left in the cache', () => {
// Past staleMaxAge the entry is gone, and the outage surfaces as before.
getStale.mockReturnValue(null);
expect(() => staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums')).toThrow(
ImmichUnavailableError,
);
});
it('does not serve stale data for an error that is not an outage', () => {
// A TypeError means we asked the wrong question. Answering it with
// yesterday's data would hide a real bug behind a plausible page.
getStale.mockReturnValue([{ id: 'album-1' }]);
expect(() => staleOrThrow('albums', new TypeError('read of undefined'), 'albums')).toThrow(
TypeError,
);
expect(getStale).not.toHaveBeenCalled();
});
it('says so in the log when it serves something stale', () => {
getStale.mockReturnValue(['cached']);
staleOrThrow('albums', new ImmichUnavailableError('502'), 'album list');
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('album list'));
});
});
describe('the MISSING sentinel (#626)', () => {
it('is never cast into real data by staleOrThrow', () => {
// The sentinel handed back as an album is a truthy object with no fields;
// every `if (!album)` guard passes and the page answers 500. For a key
// that should only ever hold data, meeting it means nothing is usable.
getStale.mockReturnValue(MISSING);
expect(() => staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums')).toThrow(
ImmichUnavailableError,
);
});
it('comes back from staleOrMissing as null — the last authoritative answer', () => {
getStale.mockReturnValue(MISSING);
expect(staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toBeNull();
});
it('lets staleOrMissing still serve real stale data', () => {
getStale.mockReturnValue({ id: 'asset-x' });
expect(staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toEqual({
id: 'asset-x',
});
});
it('lets staleOrMissing throw when nothing is cached', () => {
getStale.mockReturnValue(null);
expect(() => staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toThrow(
ImmichUnavailableError,
);
});
it('keeps staleOrMissing from answering a non-outage error', () => {
getStale.mockReturnValue(MISSING);
expect(() => staleOrMissing('asset-x', new TypeError('bug'), 'asset x')).toThrow(TypeError);
expect(getStale).not.toHaveBeenCalled();
});
});
describe('cacheSet', () => {
it('passes the configured windows through', () => {
cacheSet('albums', ['a'], 60_000, 3_600_000);
expect(set).toHaveBeenCalledWith('albums', ['a'], 60_000, 3_600_000);
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { requestJson, ImmichUnavailableError, isTimeout } from '../immichTransport';
/**
* The distinction these tests pin is the one that matters to visitors: `null`
* means Immich answered and the thing is gone, an ImmichUnavailableError means
* Immich could not answer at all. Conflating them once rendered every album URL
* as a hard 404 during an outage — including albums that exist.
*/
const opts = {
apiUrl: 'http://immich.test/api',
apiKey: 'test-key',
timeoutMs: 5000,
endpoint: '/albums',
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('requestJson', () => {
it('returns the parsed body', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => json([{ id: 'album-1' }])),
);
await expect(requestJson(opts)).resolves.toEqual([{ id: 'album-1' }]);
});
it('sends the API key and asks for JSON', async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
vi.stubGlobal('fetch', fetchMock);
await requestJson(opts);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://immich.test/api/albums');
expect(init).toBeDefined();
expect(init?.method).toBe('GET');
expect(init?.headers).toMatchObject({ 'x-api-key': 'test-key', Accept: 'application/json' });
});
it('posts when given a body', async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
vi.stubGlobal('fetch', fetchMock);
await requestJson({ ...opts, body: { albumId: 'a1' } });
const [, init] = fetchMock.mock.calls[0];
expect(init?.method).toBe('POST');
expect(init?.body).toBe('{"albumId":"a1"}');
});
it('returns null for a resource that is genuinely gone', async () => {
for (const status of [404, 410]) {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status })),
);
await expect(requestJson(opts)).resolves.toBeNull();
}
});
it('throws for a status that means "not right now"', async () => {
// A 5xx, a rate limit or a rejected API key are outages, not deletions.
for (const status of [401, 429, 500, 502, 503]) {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status, statusText: 'Nope' })),
);
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
}
});
it('carries the status on the error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 503, statusText: 'Unavailable' })),
);
await expect(requestJson(opts)).rejects.toMatchObject({ status: 503 });
});
/**
* Neither the "gone" nor the "outage" branch reads the body. Under undici
* an unconsumed one keeps its socket out of the pool until GC finalises it,
* so a proxy answering every request with an error accumulates orphaned
* sockets for as long as the fault lasts (#635).
*/
it('cancels the body on the "gone" path (404/410)', async () => {
for (const status of [404, 410]) {
const res = new Response('', { status });
const cancelSpy = vi.spyOn(res.body!, 'cancel');
vi.stubGlobal(
'fetch',
vi.fn(async () => res),
);
await requestJson(opts);
expect(cancelSpy).toHaveBeenCalledOnce();
}
});
it('cancels the body on the "outage" path (5xx)', async () => {
const res = new Response('', { status: 503, statusText: 'Unavailable' });
const cancelSpy = vi.spyOn(res.body!, 'cancel');
vi.stubGlobal(
'fetch',
vi.fn(async () => res),
);
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('cancels the body when the content type is not JSON', async () => {
const res = new Response('<html>502 Bad Gateway</html>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
const cancelSpy = vi.spyOn(res.body!, 'cancel');
vi.stubGlobal(
'fetch',
vi.fn(async () => res),
);
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('refuses a non-JSON body', async () => {
// We always send Accept: application/json, so an HTML body is a gateway
// error page, not an answer about the resource.
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response('<html>502 Bad Gateway</html>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
}),
),
);
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
});
it('reports a timeout as such, with the budget in the message', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
}),
);
await expect(requestJson(opts)).rejects.toThrow(/did not respond within 5000ms/);
});
it('reports an unreachable host separately from a timeout', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed');
}),
);
await expect(requestJson(opts)).rejects.toThrow(/Cannot reach Immich/);
});
it('turns a malformed body into an outage rather than a crash', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response('{ not json', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
await expect(requestJson(opts)).rejects.toThrow(/malformed JSON/);
});
});
describe('isTimeout', () => {
it('recognises both ways of giving up waiting', () => {
expect(isTimeout(Object.assign(new Error(''), { name: 'TimeoutError' }))).toBe(true);
expect(isTimeout(Object.assign(new Error(''), { name: 'AbortError' }))).toBe(true);
expect(isTimeout(new TypeError('fetch failed'))).toBe(false);
expect(isTimeout('not an error')).toBe(false);
});
});

View File

@@ -12,6 +12,13 @@ vi.mock('@/lib/env', () => ({
return process.env.INSTALL_CONTENT_DIR;
},
},
normalizeApiUrl: (raw: string) => {
try {
return new URL(raw).toString().replace(/\/+$/, '');
} catch {
return '';
}
},
}));
let dir: string;

View File

@@ -23,6 +23,15 @@ vi.mock('@/lib/env', () => ({
return process.env.INSTALL_CONTENT_DIR || '';
},
},
// Real implementation, not a stub: install.ts's own normalisation tests
// (#634) rely on this actually stripping trailing slashes.
normalizeApiUrl: (raw: string) => {
try {
return new URL(raw).toString().replace(/\/+$/, '');
} catch {
return '';
}
},
}));
/** Import fresh so the module-level install-file cache cannot leak between cases. */
@@ -103,6 +112,45 @@ describe('getInstallCredentials', () => {
adminPassword: '',
});
});
/**
* The environment path already strips a trailing slash (lib/env.ts); the
* file path used to store content/install.json's apiUrl completely
* unnormalised. `https://host/api/` then reached getConfig() as
* `.../api/`, which does not `endsWith('/api')`, so it became
* `.../api//api` and every request 404ed (#634). Normalising on every read
* means an install.json written before this fix heals without re-running
* the wizard.
*/
describe('heals an unnormalised URL already on disk (#634)', () => {
it.each([
['trailing slash, no /api', 'https://file-immich:2283/', 'https://file-immich:2283'],
[
'trailing slash, with /api',
'https://file-immich:2283/api/',
'https://file-immich:2283/api',
],
['no trailing slash, already clean', 'https://file-immich:2283', 'https://file-immich:2283'],
])('file path: %s', async (_name, stored, expected) => {
const { getInstallCredentials } = await loadInstall();
fs.writeFileSync(
path.join(dir, 'install.json'),
JSON.stringify({ apiUrl: stored, apiKey: 'file-key' }),
);
expect(getInstallCredentials().apiUrl).toBe(expected);
});
it.each([
['trailing slash, no /api', 'https://env-immich:2283/', 'https://env-immich:2283'],
['trailing slash, with /api', 'https://env-immich:2283/api/', 'https://env-immich:2283/api'],
])('env path: %s', async (_name, envValue, expected) => {
const { getInstallCredentials } = await loadInstall();
process.env.__T_INSTALL_URL = envValue;
expect(getInstallCredentials().apiUrl).toBe(expected);
});
});
});
describe('isInstalled', () => {
@@ -168,6 +216,23 @@ describe('completeInstall', () => {
expect(getInstallCredentials().authSecret).toBe(creds.authSecret);
});
/**
* The route verifies the connection against normalizeApiBase(apiUrl),
* which strips a trailing slash, but this used to write input.apiUrl.trim()
* unchanged — so a URL that passed the ping test was stored as typed and
* every request afterwards 404ed (#634).
*/
it.each([
['trailing slash, no /api', 'https://immich.example/', 'https://immich.example'],
['trailing slash, with /api', 'https://immich.example/api/', 'https://immich.example/api'],
])('normalises the stored apiUrl: %s', async (_name, typed, expected) => {
const { completeInstall } = await loadInstall();
await completeInstall({ apiUrl: typed, apiKey: 'api-key-123' });
const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
expect(creds.apiUrl).toBe(expected);
});
it('writes an empty albums list when no albums are selected', async () => {
const { completeInstall } = await loadInstall();
await completeInstall({
@@ -202,6 +267,68 @@ describe('completeInstall', () => {
expect(isInstalled()).toBe(true);
});
/**
* isInstalled() is false whenever Immich credentials fail to resolve,
* whether or not gallery.yaml exists — a bad IMMICH_API_URL or a corrupt
* install.json is enough. Re-running the wizard after that used to replace
* every subpage, section, password, assetOrder and per-album override with
* a two-key skeleton (#627).
*/
it('does not overwrite an existing gallery.yaml', async () => {
const { completeInstall } = await loadInstall();
const galleryPath = path.join(dir, 'gallery.yaml');
const existing = 'albums:\n - existing-album\nsubpages:\n - slug: trips\n';
fs.writeFileSync(galleryPath, existing);
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
albums: [ALBUM_ID],
});
expect(result.galleryWritten).toBe(false);
expect(fs.readFileSync(galleryPath, 'utf8')).toBe(existing);
});
it('does not overwrite an existing settings.yaml', async () => {
const { completeInstall } = await loadInstall();
const settingsPath = path.join(dir, 'settings.yaml');
const existing = 'title: "Existing Site"\n';
fs.writeFileSync(settingsPath, existing);
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
siteTitle: 'New Title',
});
expect(result.settingsWritten).toBe(false);
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(existing);
});
it('writes both files on a genuinely fresh install and reports it', async () => {
const { completeInstall } = await loadInstall();
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
});
expect(result).toEqual({ galleryWritten: true, settingsWritten: true });
});
it('still writes fresh credentials into install.json when gallery.yaml is kept', async () => {
const { completeInstall, getInstallCredentials } = await loadInstall();
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'fresh-key',
});
expect(getInstallCredentials().apiKey).toBe('fresh-key');
});
});
describe('normalizeApiBase', () => {

View File

@@ -14,12 +14,14 @@ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
let root: string;
let journalDir: string;
let backupDir: string;
let essaysDir: string;
let service: typeof import('../admin/journal-service');
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-'));
journalDir = path.join(root, 'content', 'journal');
backupDir = path.join(journalDir, '.backups');
essaysDir = path.join(root, 'content', 'essays');
await mkdir(journalDir, { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -62,6 +64,56 @@ describe('deleteJournalEntry', () => {
});
});
describe('writeJournalEntry backup failure (#630)', () => {
it('still saves a brand-new entry with no backup', async () => {
await expect(service.writeJournalEntry('trip', entry('New'))).resolves.toBeUndefined();
await expect(readdir(backupDir).catch(() => [])).resolves.toEqual([]);
});
it('aborts the save when the entry exists but the backup cannot be written', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Old'));
// A file where the backup directory should be makes mkdir and copy fail.
await writeFile(backupDir, 'not a directory');
await expect(service.writeJournalEntry('trip', entry('New'))).rejects.toThrow();
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Old'));
});
});
describe('legacy content/essays/ entries (#631)', () => {
it('retires the legacy copy once a legacy entry is edited', async () => {
await mkdir(essaysDir, { recursive: true });
await writeFile(path.join(essaysDir, 'trip.md'), entry('Old'));
await service.writeJournalEntry('trip', entry('New'));
await expect(readdir(essaysDir)).resolves.not.toContain('trip.md');
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('New'));
});
it('does not resurrect a legacy entry after it is edited then deleted', async () => {
await mkdir(essaysDir, { recursive: true });
await writeFile(path.join(essaysDir, 'trip.md'), entry('Old'));
await service.writeJournalEntry('trip', entry('New'));
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
const entries = await service.listJournalEntries();
expect(entries.map((e) => e.slug)).not.toContain('trip');
});
it('deletes a legacy entry that was never edited, from content/essays/', async () => {
await mkdir(essaysDir, { recursive: true });
await writeFile(path.join(essaysDir, 'trip.md'), entry('Legacy'));
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
await expect(readdir(essaysDir)).resolves.not.toContain('trip.md');
const entries = await service.listJournalEntries();
expect(entries.map((e) => e.slug)).not.toContain('trip');
});
});
describe('restoreJournalBackup', () => {
it('brings a deleted entry back under its slug', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
/**
* serializeJournalMarkdown must be the exact inverse of the inline renderer
* for the constructs it produces, or a save silently rewrites the file (#628).
*/
const roundTrip = (source: string) => serializeJournalMarkdown(parseJournalMarkdown(source));
describe('journal round trip (#628)', () => {
it('keeps a link through save', () => {
const source = 'Read more at [Immich](https://immich.app) today.';
const once = roundTrip(source);
expect(once).toBe(source);
expect(roundTrip(once)).toBe(once);
});
it('keeps emphasis inside a quote as markdown, not literal tags', () => {
const source = '> **Yes** he said -- Someone';
const once = roundTrip(source);
expect(once).toBe(source);
expect(once).not.toContain('<strong>');
});
it('keeps emphasis inside a photo caption as markdown', () => {
const source = '![abc123:wide](A **bold** caption)';
const once = roundTrip(source);
expect(once).toBe(source);
});
it('does not double-encode an apostrophe in a photo-pair caption', () => {
const source = "![abc123, def456](Anna's photos)";
const once = roundTrip(source);
expect(once).toBe(source);
expect(roundTrip(once)).toBe(once);
});
it('leaves intraword underscores alone', () => {
const source = 'See my_photo_2024.jpg for details.';
const once = roundTrip(source);
expect(once).toBe(source);
});
it('still renders underscore emphasis with word boundaries either side', () => {
const source = 'This is _emphasised_ text.';
const parsed = parseJournalMarkdown(source);
expect(parsed.blocks[0]).toMatchObject({
type: 'paragraph',
html: 'This is <em>emphasised</em> text.',
});
// `_` and `*` both parse to <em>, so a `_`-emphasised word comes back with
// `*` — that normalisation is expected. What must hold is idempotence.
const once = roundTrip(source);
expect(roundTrip(once)).toBe(once);
});
it('round-trips a link nested inside bold text', () => {
const source = '**[Immich](https://immich.app)** is great.';
const once = roundTrip(source);
expect(once).toBe(source);
});
it('round-trips bold text nested inside a link label', () => {
const source = '[**Immich**](https://immich.app) is great.';
const once = roundTrip(source);
expect(once).toBe(source);
});
it('is idempotent on a full multi-block entry', () => {
const source = [
'---',
'title: "A trip"',
'---',
'',
'# Heading',
'',
'Read more at [Immich](https://immich.app), it is **great** and *fun*.',
'',
'> **Yes** he said -- Someone',
'',
'![abc123:wide](A **bold** caption)',
'',
"![abc123, def456](Anna's photos)",
].join('\n');
const once = roundTrip(source);
const twice = roundTrip(once);
expect(twice).toBe(once);
expect(once).toContain('[Immich](https://immich.app)');
expect(once).toContain("Anna's photos");
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { journalNeighbours } from '@/lib/journalNav';
const entry = (slug: string, title = slug.toUpperCase()) => ({ slug, title });
const three = [entry('a'), entry('b'), entry('c')];
describe('journalNeighbours', () => {
it('offers both neighbours in the middle of a list', () => {
expect(journalNeighbours(three, 'b')).toEqual({
prev: { href: '/journal/a', title: 'A' },
next: { href: '/journal/c', title: 'C' },
});
});
it('omits the previous link on the first entry', () => {
expect(journalNeighbours(three, 'a')).toEqual({ next: { href: '/journal/b', title: 'B' } });
});
it('omits the next link on the last entry — no wrap-around', () => {
expect(journalNeighbours(three, 'c')).toEqual({ prev: { href: '/journal/b', title: 'B' } });
});
it('returns nothing for a single-entry list', () => {
expect(journalNeighbours([entry('a')], 'a')).toEqual({});
});
it('returns nothing for an empty list', () => {
expect(journalNeighbours([], 'a')).toEqual({});
});
it('returns nothing when the entry is not in the list', () => {
expect(journalNeighbours(three, 'zzz')).toEqual({});
});
it('follows list order, not slug or title order', () => {
const shuffled = [entry('c'), entry('a'), entry('b')];
expect(journalNeighbours(shuffled, 'a')).toEqual({
prev: { href: '/journal/c', title: 'C' },
next: { href: '/journal/b', title: 'B' },
});
});
it('falls back to whatever title is passed in, unchanged', () => {
const list = [entry('untitled-entry', 'untitled-entry'), entry('b')];
expect(journalNeighbours(list, 'b').prev).toEqual({
href: '/journal/untitled-entry',
title: 'untitled-entry',
});
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* `!exif?.latitude` treats a coordinate of exactly 0 as absent, so a photo on
* the equator or the prime meridian never reached the map (#635).
*/
const getAlbums = vi.fn();
const getAlbum = vi.fn();
vi.mock('@/lib/immich', () => ({
immich: {
getAlbums: (...args: unknown[]) => getAlbums(...args),
getAlbum: (...args: unknown[]) => getAlbum(...args),
},
}));
vi.mock('@/lib/config', () => ({
getConfig: () => ({ subpages: [], cacheTtl: 60_000 }),
}));
vi.mock('@/lib/cache', () => ({
cache: { get: () => undefined, set: () => {} },
}));
import { getMapData } from '@/lib/mapService';
const ALBUM = { id: 'album-1', albumName: 'Trip', slug: 'trip' };
function asset(id: string, exif: Record<string, unknown>) {
return { id, exifInfo: exif };
}
beforeEach(() => {
getAlbums.mockReset();
getAlbum.mockReset();
getAlbums.mockResolvedValue([ALBUM]);
});
describe('getMapData coordinate filtering (#635)', () => {
it('includes a photo at latitude 0 (the equator)', async () => {
getAlbum.mockResolvedValue({
id: ALBUM.id,
assets: [asset('a', { latitude: 0, longitude: 12.5, city: 'Somewhere', country: 'Kenya' })],
});
const locations = await getMapData();
expect(locations).toHaveLength(1);
expect(locations[0].albums[0].photoCount).toBe(1);
expect(locations[0].albums[0].latSum).toBe(0);
});
it('includes a photo at longitude 0 (the prime meridian)', async () => {
getAlbum.mockResolvedValue({
id: ALBUM.id,
assets: [asset('a', { latitude: 51.5, longitude: 0, city: 'London', country: 'UK' })],
});
const locations = await getMapData();
expect(locations).toHaveLength(1);
expect(locations[0].albums[0].photoCount).toBe(1);
});
it('still excludes a photo with no coordinates at all', async () => {
getAlbum.mockResolvedValue({
id: ALBUM.id,
assets: [asset('a', { latitude: null, longitude: null, city: 'Nowhere', country: 'None' })],
});
const locations = await getMapData();
expect(locations).toHaveLength(0);
});
it('still excludes a photo missing city or country', async () => {
getAlbum.mockResolvedValue({
id: ALBUM.id,
assets: [asset('a', { latitude: 0, longitude: 0, city: '', country: 'Kenya' })],
});
const locations = await getMapData();
expect(locations).toHaveLength(0);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { isActivePath } from '@/components/NavLink';
/**
* The header has styled `.active` since it was written and never applied it.
* These pin the matching rules, in particular the two that are easy to get
* wrong: Home must not light up everywhere, and one subpage slug must not
* match another that merely starts the same way.
*/
describe('isActivePath', () => {
it('marks Home only on the home page', () => {
expect(isActivePath('/', '/')).toBe(true);
expect(isActivePath('/reisen', '/')).toBe(false);
expect(isActivePath('/journal/island', '/')).toBe(false);
});
it('marks a subpage on its own page', () => {
expect(isActivePath('/reisen', '/reisen')).toBe(true);
});
it('marks a subpage while inside one of its albums', () => {
// An album lives at /<subpage>/<album>, and the visitor is still "in" that
// section of the site.
expect(isActivePath('/reisen/island', '/reisen')).toBe(true);
});
it('does not let one slug match another that starts the same way', () => {
expect(isActivePath('/reisender', '/reisen')).toBe(false);
expect(isActivePath('/reisen-2026', '/reisen')).toBe(false);
});
it('marks Journal on an entry as well as the index', () => {
expect(isActivePath('/journal', '/journal')).toBe(true);
expect(isActivePath('/journal/eine-reise', '/journal')).toBe(true);
});
it('marks nothing when the path is unknown', () => {
expect(isActivePath(null, '/about')).toBe(false);
});
it('leaves unrelated links alone', () => {
expect(isActivePath('/about', '/map')).toBe(false);
expect(isActivePath('/map', '/about')).toBe(false);
});
it('marks exactly one link for a typical header', () => {
const header = ['/', '/reisen', '/journal', '/about', '/map'];
const activeOn = (pathname: string) => header.filter((h) => isActivePath(pathname, h));
expect(activeOn('/')).toEqual(['/']);
expect(activeOn('/reisen')).toEqual(['/reisen']);
expect(activeOn('/reisen/island')).toEqual(['/reisen']);
expect(activeOn('/journal/eine-reise')).toEqual(['/journal']);
expect(activeOn('/impressum')).toEqual([]);
});
});

View File

@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { parsePhotoHash, buildPhotoHash, buildPhotoPermalink } from '@/lib/photoHash';
import {
parsePhotoHash,
buildPhotoHash,
parsePhotoQuery,
buildPhotoQuery,
buildPhotoPermalink,
} from '@/lib/photoHash';
describe('buildPhotoHash', () => {
it('presents the index 1-indexed', () => {
@@ -33,6 +39,46 @@ describe('parsePhotoHash', () => {
});
});
describe('parsePhotoQuery', () => {
it('reads the photo param', () => {
expect(parsePhotoQuery('?photo=v2:abc123')).toBe('v2:abc123');
});
it('returns null when absent', () => {
expect(parsePhotoQuery('')).toBeNull();
expect(parsePhotoQuery('?preview=true')).toBeNull();
});
it('ignores an empty value', () => {
// URLSearchParams reads `?photo=` as the empty string, not absent — callers
// treat an empty token the same as "not addressing a photo" downstream,
// but this function reports exactly what was in the URL.
expect(parsePhotoQuery('?photo=')).toBe('');
});
});
describe('buildPhotoQuery', () => {
it('adds the photo param to an empty search string', () => {
expect(buildPhotoQuery('', 'v2:abc')).toBe('?photo=v2%3Aabc');
});
it('preserves other query parameters', () => {
expect(buildPhotoQuery('?preview=true', 'v2:abc')).toBe('?preview=true&photo=v2%3Aabc');
});
it('replaces an existing photo param rather than duplicating it', () => {
expect(buildPhotoQuery('?photo=old', 'v2:new')).toBe('?photo=v2%3Anew');
});
it('removes the param when assetId is null', () => {
expect(buildPhotoQuery('?photo=v2:abc&preview=true', null)).toBe('?preview=true');
});
it('returns an empty string when nothing is left', () => {
expect(buildPhotoQuery('?photo=v2:abc', null)).toBe('');
});
});
describe('buildPhotoPermalink', () => {
const location = {
origin: 'https://folio.example',
@@ -40,19 +86,30 @@ describe('buildPhotoPermalink', () => {
search: '',
};
it('builds an absolute link to the photo', () => {
expect(buildPhotoPermalink(location, 2)).toBe('https://folio.example/travel/iceland#photo-3');
it('builds an absolute link to the photo, addressed by its stable id', () => {
expect(buildPhotoPermalink(location, 'v2:abc123')).toBe(
'https://folio.example/travel/iceland?photo=v2%3Aabc123',
);
});
it('preserves an existing query string', () => {
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 0)).toBe(
'https://folio.example/travel/iceland?preview=true#photo-1',
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 'v2:abc123')).toBe(
'https://folio.example/travel/iceland?preview=true&photo=v2%3Aabc123',
);
});
it('works at the site root', () => {
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 0)).toBe(
'https://folio.example/#photo-1',
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 'v2:abc123')).toBe(
'https://folio.example/?photo=v2%3Aabc123',
);
});
// The whole point: the same asset keeps the same link no matter where it
// now sits in the album (#588). The old #photo-N form could not promise
// that at all — it named a position, not a photo.
it('does not change when the asset moves to a different position', () => {
expect(buildPhotoPermalink(location, 'v2:abc123')).toBe(
buildPhotoPermalink(location, 'v2:abc123'),
);
});
});

View File

@@ -13,6 +13,7 @@ vi.mock('@/lib/auth', () => ({
}));
import { isSiteUnlocked } from '@/lib/auth';
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
import { proxy, config } from '@/proxy';
const mockUnlocked = isSiteUnlocked as unknown as ReturnType<typeof vi.fn>;
@@ -88,6 +89,53 @@ describe('proxy', () => {
expect(source).not.toContain('admin');
});
/**
* Reading the pattern is not enough: whether a path reaches proxy() is
* decided by the regex Next compiles from it, so that is what gets tested.
* A path the matcher skips gets no site gate and no CSP.
*/
describe('matcher, compiled the way Next compiles it', () => {
const compiled = tryToParsePath(config.matcher[0]);
const reaches = (pathname: string) => new RegExp(compiled.regexStr!).test(pathname);
it('compiles', () => {
expect(compiled.error).toBeUndefined();
expect(compiled.regexStr).toBeTruthy();
});
it.each(['/', '/journal', '/japan/kyoto', '/admin', '/admin/pages', '/gate', '/install'])(
'runs on %s',
(pathname) => expect(reaches(pathname)).toBe(true),
);
it.each([
'/api',
'/api/image/v2:abc',
'/_next/static/chunks/main.js',
'/_next/image',
'/favicon.ico',
'/sitemap.xml',
'/robots.txt',
])('skips %s', (pathname) => expect(reaches(pathname)).toBe(false));
// An exclusion is a whole segment or a whole filename, never a prefix: a
// page whose slug happens to start with one must still be gated.
it.each([
'/apia-samoa',
'/apiary',
'/api-docs',
'/_next/staticfoo',
'/_next/imagery',
'/sitemapXxml',
'/robots1txt',
'/faviconXico',
'/robots.txt.bak',
'/sitemap.xml/extra',
])('runs on %s, which only looks like an exclusion', (pathname) =>
expect(reaches(pathname)).toBe(true),
);
});
/**
* The gate resolves the site password through getConfig(), which throws on a
* config it cannot parse — inside the proxy, before app/layout.tsx can fall

View File

@@ -252,3 +252,77 @@ describe('checkRateLimit', () => {
expect(result.remaining).toBe(4);
});
});
/**
* The previous limiter reset its whole budget the moment `expiresAt` (set
* when the window opened, never advanced) passed. A burst timed around that
* boundary — most of one window, then most of the next — passed roughly
* double the documented limit. 10 admin password attempts landed in one
* second instead of 5 per minute.
*/
describe('checkRateLimit is a sliding window, not a fixed one', () => {
let testIp: string;
let counter = 0;
beforeEach(() => {
counter++;
testIp = `sliding-ip-${counter}-${Date.now()}`;
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());
it('does not grant a fresh budget the instant a window rolls over', () => {
const maxRpm = 10;
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
// Exactly one window later: a fixed window resets to a full fresh
// budget here. The previous window's requests still fully overlap the
// trailing 60s at this exact instant, so this must still be refused.
vi.advanceTimersByTime(60_000);
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
});
it('never lets a boundary-timed burst clear roughly double the limit', () => {
const maxRpm = 10;
// Spend the whole budget right at the end of the first window.
vi.advanceTimersByTime(59_000);
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
// Cross into the next window and try to spend the whole budget again,
// one request per second — a fixed window allows every one of these.
let allowedInSecondWindow = 0;
for (let i = 0; i < maxRpm; i++) {
vi.advanceTimersByTime(1000);
if (checkRateLimit(testIp, maxRpm).success) allowedInSecondWindow++;
}
// A tolerant bound, not an exact one: the point is "nowhere near double",
// not pinning the estimator's exact rounding.
expect(allowedInSecondWindow).toBeLessThan(maxRpm);
});
it('fully forgets a client after two windows of inactivity', () => {
const maxRpm = 3;
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
vi.advanceTimersByTime(2 * 60_000 + 1);
const result = checkRateLimit(testIp, maxRpm);
expect(result.success).toBe(true);
expect(result.remaining).toBe(maxRpm - 1);
});
it('lets the budget recover gradually as the old window ages out', () => {
const maxRpm = 10;
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
// Halfway through the next window, roughly half of the previous burst has
// aged out of the trailing 60s — some budget should have come back.
vi.advanceTimersByTime(90_000);
const result = checkRateLimit(testIp, maxRpm);
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { validateSettings } from '../config/settingsSchema';
/**
* Half of these tests are about what must *not* be rejected.
*
* The failure mode of a validator on a config file is not "it let something
* through" — it is "it locked an operator out of their own settings, through
* the panel that writes the file". There is no schemaVersion and no migration
* code, so older and newer configurations are both in the wild.
*/
const ok = (input: unknown) => validateSettings(input).ok;
const errors = (input: unknown) => {
const result = validateSettings(input);
return result.ok ? [] : result.errors;
};
describe('validateSettings — accepts what works today', () => {
it('accepts the example settings file this repo ships with', () => {
// content/settings.yaml is gitignored — the example is the one every
// installation starts from, and the one CI actually has.
const raw = fs.readFileSync(path.join(process.cwd(), 'content/settings.yaml.example'), 'utf8');
expect(validateSettings(yaml.load(raw))).toEqual({ ok: true });
});
it('accepts an empty settings file', () => {
// Everything has a default; a site with no settings.yaml is a valid site.
expect(ok({})).toBe(true);
});
it('accepts both spellings of theme', () => {
expect(ok({ theme: 'minimal' })).toBe(true);
expect(ok({ theme: { preset: 'minimal', accent: '#c0ffee', radius: 4 } })).toBe(true);
});
it('accepts a watermark opacity written as a percentage', () => {
// The #508 workaround: values above 1 are read as percentages. A validator
// that insisted on 0–1 would reject configurations that render correctly.
expect(ok({ watermark: { opacity: 90 } })).toBe(true);
expect(ok({ watermark: { opacity: 0.5 } })).toBe(true);
});
it('accepts a colour mode it does not recognise', () => {
// resolveColorMode falls back to 'dark'. Rejecting here would be stricter
// than the behaviour it guards.
expect(ok({ mode: 'sepia' })).toBe(true);
});
it('accepts keys this version has never heard of', () => {
expect(ok({ title: 'Folio', somethingFromANewerVersion: { nested: true } })).toBe(true);
expect(ok({ theme: { preset: 'noir', futureKnob: 3 } })).toBe(true);
});
it('accepts the legacy exifOnHover switch alongside the group form', () => {
expect(ok({ exifOnHover: false, exif: { camera: true, caption: false } })).toBe(true);
});
});
describe('validateSettings — rejects what breaks the site', () => {
it('rejects a payload that is not an object', () => {
expect(ok('title: Folio')).toBe(false);
expect(ok([{ title: 'Folio' }])).toBe(false);
expect(ok(null)).toBe(false);
});
it('rejects a scalar where a section belongs', () => {
// `grid: 3` reads as "three columns" to a human and as a crash to
// buildCoverGridVars.
const found = errors({ grid: 3 });
expect(found).toHaveLength(1);
expect(found[0].field).toBe('grid');
});
it('rejects a section where a scalar belongs', () => {
const found = errors({ title: { de: 'Folio', en: 'Folio' } });
expect(found[0].field).toBe('title');
});
it('names the exact field, however deep', () => {
const found = errors({ theme: { fonts: { heading: 42 } } });
expect(found.map((e) => e.field)).toContain('theme.fonts.heading');
});
it('rejects a theme that is neither a preset name nor a section', () => {
// The separate theme check must not turn into a hole: anything that is not
// a string still has to look like the object form.
expect(ok({ theme: 3 })).toBe(false);
expect(ok({ theme: ['minimal'] })).toBe(false);
expect(errors({ theme: 3 })[0].field).toBe('theme');
});
it('names an index inside navLinks', () => {
const found = errors({ navLinks: [{ label: 'Shop', url: 'https://example.com' }, 'nope'] });
expect(found[0].field).toBe('navLinks.1');
});
it('rejects a radius written with its unit', () => {
// #633: `radius: 8px` reaches CSS as `8pxpx`. The value itself belongs to
// the resolver, but a string where a number belongs is a type error, and
// that half is caught here for anything saved from the panel.
const found = errors({ theme: { radius: '8px' } });
expect(found.map((e) => e.field)).toContain('theme.radius');
});
it('rejects a list where a record belongs', () => {
expect(ok({ legal: ['Ralf', 'Berlin'] })).toBe(false);
});
it('reports every offending field at once, not just the first', () => {
const found = errors({ title: 1, subtitle: 2, map: 'yes' });
expect(found.map((e) => e.field).sort()).toEqual(['map', 'subtitle', 'title']);
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { resolveTheme, THEME_PRESETS, DEFAULT_PRESET } from '@/lib/config/theme';
/**
* `radius` reaches CSS as `${radius}px` and `${radius * 1.5}px` (app/layout.tsx),
* so a value that is not actually a number does not fail loudly — it becomes
* `8pxpx` and `NaNpx` (#633). Hand-edited YAML can hand resolveTheme anything;
* only the panel-save boundary (settingsSchema.ts) already caught the type
* half of this, and only for a save from the panel.
*/
describe('resolveTheme scalar coercion (#633)', () => {
const base = THEME_PRESETS[DEFAULT_PRESET];
it('falls back to the preset radius when the value is a string', () => {
// as unknown as number: simulating hand-edited YAML, which yaml.load()
// hands back with no runtime guarantee it matches SettingsYaml's types.
const theme = resolveTheme({ radius: '8px' as unknown as number });
expect(theme.radius).toBe(base.radius);
expect(Number.isFinite(theme.radius)).toBe(true);
});
it('falls back to the preset radius when the value is NaN', () => {
expect(resolveTheme({ radius: NaN }).radius).toBe(base.radius);
});
it('clamps an out-of-range radius rather than passing it through', () => {
expect(resolveTheme({ radius: 999 }).radius).toBeLessThanOrEqual(64);
expect(resolveTheme({ radius: -5 }).radius).toBe(0);
});
it('keeps a legitimate radius override', () => {
expect(resolveTheme({ radius: 24 }).radius).toBe(24);
});
it('falls back to the preset accent when the value is not a string', () => {
const theme = resolveTheme({ accent: 42 as unknown as string });
expect(theme.accent).toBe(base.accent);
});
it('falls back to the preset accent for an empty string', () => {
expect(resolveTheme({ accent: '' }).accent).toBe(base.accent);
});
it('keeps a legitimate accent override', () => {
expect(resolveTheme({ accent: '#123456' }).accent).toBe('#123456');
});
it('falls back to the preset grain/headerDot when not a boolean', () => {
const theme = resolveTheme({
grain: 'yes' as unknown as boolean,
headerDot: 1 as unknown as boolean,
});
expect(theme.grain).toBe(base.grain);
expect(theme.headerDot).toBe(base.headerDot);
});
it('keeps legitimate grain/headerDot overrides', () => {
const theme = resolveTheme({ grain: !base.grain, headerDot: !base.headerDot });
expect(theme.grain).toBe(!base.grain);
expect(theme.headerDot).toBe(!base.headerDot);
});
});

View File

@@ -80,3 +80,36 @@ describe('writeYamlFile temp file handling', () => {
expect(fs.unlink).not.toHaveBeenCalled();
});
});
/**
* "No file yet" and "the backup could not be written" used to share one
* catch, so a `.backups/` this save couldn't write to looked exactly like a
* brand-new file and the save went ahead with no snapshot (#630).
*/
describe('writeYamlFile backup failure', () => {
beforeEach(() => vi.clearAllMocks());
it('still saves a brand-new file with no backup', async () => {
await expect(writeGalleryYaml(gallery)).resolves.toBeUndefined();
expect(fs.copyFile).not.toHaveBeenCalled();
});
it('aborts the save when the file exists but the backup copy fails', async () => {
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
vi.mocked(fs.copyFile).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES');
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('aborts when checking whether the file exists fails for a reason other than ENOENT', async () => {
vi.mocked(fs.access).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES');
expect(fs.writeFile).not.toHaveBeenCalled();
});
});

View File

@@ -147,6 +147,24 @@ export function checkProxyHops(
};
}
// configuredHops is between 0 and provenHops (both handled above), so the
// position read is still inside the chain rather than at its start: every
// request is read from a proxy's own address, not the client behind it.
// Cloudflare (1 hop) in front of nginx (1 more, 2 total) with the value set
// to 1 reads the Cloudflare edge address, and every visitor behind that
// edge shares one rate-limit bucket — this used to report "matches the
// observed chain".
if (configuredHops < provenHops) {
return {
id: 'proxy-hops',
level: 'warn',
title: `TRUSTED_PROXY_HOPS is ${configuredHops}, but a chain of ${provenHops} proxy hops was seen`,
detail:
'The client IP is read from a position still inside the proxy chain, not the real client — ' +
`every visitor behind that hop can share one rate-limit bucket. ${provenHops} is what this request suggests.`,
};
}
return {
id: 'proxy-hops',
level: 'ok',
@@ -244,6 +262,77 @@ export function checkAlbumsShared(configured: string[], known: AlbumRef[]): Doct
};
}
/** One set of albums that will all be reachable under the same URL prefix. */
export interface AlbumSlugGroup {
/** e.g. "gallery.yaml albums" or `subpage "Trips"` */
context: string;
albumIds: string[];
}
/**
* Album slug collisions using each album's *resolved* name — the title
* override if one is set, otherwise whatever Immich calls it. deriveGallery
* can only see the override half of that (#632); this is the other half,
* checked against the live album list the doctor already has.
*
* `slugOf` is a parameter rather than an import so this file keeps the zero
* dependencies it has had since #491 — the whole reason `scripts/doctor.mts`
* can run this module directly through Node's native TypeScript stripping,
* with no bundler and no `lib/config` (which pulls in `fs`).
*/
export function checkAlbumSlugCollisions(
groups: AlbumSlugGroup[],
overrides: Record<string, string>,
known: AlbumRef[],
slugOf: (name: string) => string,
): DoctorFinding {
const byId = new Map(known.map((a) => [a.id, a]));
const collisions: string[] = [];
const collidingIds: string[] = [];
for (const group of groups) {
const named = group.albumIds
.map((id) => ({ id, name: overrides[id] ?? byId.get(id)?.albumName }))
.filter((a): a is { id: string; name: string } => !!a.name);
const bySlug = new Map<string, typeof named>();
for (const album of named) {
const slug = slugOf(album.name);
if (!slug) continue; // Falls back to the id elsewhere, which is unique.
const list = bySlug.get(slug) ?? [];
list.push(album);
bySlug.set(slug, list);
}
for (const [slug, collided] of bySlug) {
if (collided.length < 2) continue;
collisions.push(
`${group.context}: "${collided.map((a) => a.name).join('" and "')}" all resolve to /${slug}`,
);
collidingIds.push(...collided.map((a) => a.id));
}
}
if (collisions.length) {
return {
id: 'album-slugs',
level: 'error',
title: `${collisions.length} album slug ${collisions.length === 1 ? 'collision' : 'collisions'} found`,
detail:
'Every album after the first in a collision is unreachable at its own URL, and its ' +
`settings resolve from the one before it. ${collisions.join('; ')}`,
albumIds: collidingIds,
};
}
return {
id: 'album-slugs',
level: 'ok',
title: 'No album slug collisions',
detail: 'Every album reachable under the same URL prefix has a distinct slug.',
};
}
export interface PasswordRef {
/** Where it lives, for the report: "Album japan-2024", "Site password". */
label: string;

View File

@@ -6,6 +6,7 @@
import fs from 'fs/promises';
import nodeFs from 'fs';
import path from 'path';
import { atomicWrite } from '../atomicWrite';
import {
parseJournalMarkdown,
calculateReadingTime,
@@ -19,7 +20,6 @@ const JOURNAL_DIR = path.join(process.cwd(), 'content', 'journal');
const LEGACY_ESSAYS_DIR = path.join(process.cwd(), 'content', 'essays');
const BACKUP_DIR = path.join(JOURNAL_DIR, '.backups');
const MAX_BACKUPS = 10;
let tmpCounter = 0;
/**
* Names this service writes into .backups/, and the only ones it restores from.
@@ -127,14 +127,7 @@ export async function restoreJournalBackup(backupFilename: string): Promise<stri
}
await fs.mkdir(JOURNAL_DIR, { recursive: true });
const tmpPath = `${target}.${process.pid}.${++tmpCounter}.tmp`;
try {
await fs.writeFile(tmpPath, content, 'utf8');
await fs.rename(tmpPath, target);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
await atomicWrite(target, content);
console.log(`[Journal] 🔄 Restored ${filename} from ${backupFilename}`);
return slug;
@@ -280,23 +273,42 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
throw new Error(`Invalid journal slug: "${slug}"`);
}
// Create rolling backup if file already exists
// "No file yet" and "the backup could not be written" used to share one
// catch, so a `.backups/` a save couldn't write to looked exactly like a
// brand-new entry: the save went ahead with no snapshot taken (#630). Only
// ENOENT means there is nothing to back up; anything else aborts the save
// before it overwrites the live file.
let fileExists = true;
try {
await fs.access(filePath);
await snapshotEntry(filePath, filename);
await pruneEntryBackups(filename);
} catch {
// New file, no backup needed
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
// Atomic write via unique temp file
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
try {
await fs.writeFile(tmpPath, rawMarkdown, 'utf8');
await fs.rename(tmpPath, filePath);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
if (fileExists) {
await snapshotEntry(filePath, filename);
await pruneEntryBackups(filename);
}
await atomicWrite(filePath, rawMarkdown);
// An entry that started in content/essays/ now has a copy in both
// directories; resolveJournalFilePath would keep favouring this new one,
// but deleteJournalEntry used to only ever see one path at a time and could
// leave the legacy copy behind to resurface on the next listing (#631).
// Retiring it here, once the new content is safely on disk, means delete
// never has to reconcile two files for one slug.
const legacyPath = containedPath(LEGACY_ESSAYS_DIR, filename);
if (legacyPath) {
try {
await fs.access(legacyPath);
await snapshotEntry(legacyPath, filename);
await fs.unlink(legacyPath);
console.log(`[Journal] 🧹 Retired legacy copy of ${filename} in content/essays/`);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
console.log(`[Journal] ✅ Saved ${filename}`);
@@ -316,22 +328,33 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
throw new Error(`Invalid journal slug: "${slug}"`);
}
const filePath = resolveJournalFilePath(slug);
if (!filePath) return false;
const filename = `${slug}.md`;
const primaryPath = containedPath(JOURNAL_DIR, filename);
const legacyPath = containedPath(LEGACY_ESSAYS_DIR, filename);
if (!primaryPath || !legacyPath) return false;
try {
await fs.access(filePath);
} catch {
return false;
// Check both directories, not just whichever resolveJournalFilePath would
// pick: an entry that was saved once while still in content/essays/ used to
// leave a copy there, which listJournalEntries would bring back as soon as
// the content/journal/ copy was deleted (#631). writeJournalEntry now
// retires the legacy copy on every save, but a legacy entry that was never
// edited still lives there alone, so both paths must be checked here too.
let deletedAny = false;
for (const filePath of [primaryPath, legacyPath]) {
try {
await fs.access(filePath);
} catch {
continue;
}
await snapshotEntry(filePath, filename, 'deleted');
try {
await fs.unlink(filePath);
deletedAny = true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
await snapshotEntry(filePath, `${slug}.md`, 'deleted');
try {
await fs.unlink(filePath);
console.log(`[Journal] 🗑️ Deleted ${slug}.md`);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
if (deletedAny) console.log(`[Journal] 🗑️ Deleted ${filename}`);
return deletedAny;
}

32
lib/admin/withAdmin.ts Normal file
View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { isAdminAuthenticated, isAdminEnabled } from './auth';
/**
* Wraps an admin route handler in the enabled + authenticated check.
*
* Every handler under /api/admin is defined inside this wrapper, so a route
* that forgets its guard is not a diff that merely looks fine: there is no
* plain `export async function GET` left to copy from. The one exception is
* /api/admin/auth, the login endpoint, which must stay reachable without a
* session.
*
* The two refusals are kept distinct, as the hand-written copies had them:
* 403 means the admin panel is switched off for this deployment, 401 means it
* is on and this caller is not signed in.
*
* The handler's own parameters are passed through untouched, so routes taking
* `(request)`, `(request, context)` or nothing at all all wrap the same way.
*/
export function withAdmin<Args extends unknown[]>(
handler: (...args: Args) => Response | Promise<Response>,
): (...args: Args) => Promise<Response> {
return async (...args: Args): Promise<Response> => {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
}
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return handler(...args);
};
}

View File

@@ -6,14 +6,12 @@
import fs from 'fs/promises';
import path from 'path';
import yaml from 'js-yaml';
import { atomicWrite } from '../atomicWrite';
import type { GalleryYaml, SettingsYaml } from '../config/schema';
const CONTENT_DIR = path.join(process.cwd(), 'content');
const MAX_BACKUPS = 10; // Keep last 10 backups per file
/** Disambiguates temp files within one process; the pid covers across them. */
let tmpCounter = 0;
/** Read gallery.yaml and return parsed content. */
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
try {
@@ -43,9 +41,21 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
// Ensure content directory exists
await fs.mkdir(CONTENT_DIR, { recursive: true });
// Create backup if file exists
// "No file yet" and "the backup could not be written" used to share one
// catch, so a `.backups/` a save couldn't write to (owned by root after a
// first start as root, say) looked exactly like a brand-new file: the save
// went ahead with no snapshot taken (#630). Only ENOENT means there is
// nothing to back up; anything else aborts the save before it overwrites
// the live file.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
@@ -55,8 +65,6 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
// Prune old backups
await pruneBackups(backupDir, filename);
} catch {
// File doesn't exist yet, no backup needed
}
// Generate YAML content with header comment
@@ -74,23 +82,7 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
sortKeys: false,
});
// Atomic write: write to a *unique* temp file, then rename.
//
// The rename is what makes this atomic; the temp filename is what did not.
// With a constant `${filePath}.tmp`, two saves of the same file in flight at
// once shared it — the second writeFile could interleave with the first's
// rename, leaving one save's bytes published under the other's, or a
// truncated mix. A double-clicked Save in the page builder is enough.
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
try {
await fs.writeFile(tmpPath, content, 'utf8');
await fs.rename(tmpPath, filePath);
} catch (err) {
// Do not leave litter in content/ behind a failed save. Cleanup failure is
// not worth masking the real error with.
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
await atomicWrite(filePath, content);
console.log(`[Admin] ✅ Saved ${filename}`);
}
@@ -148,31 +140,63 @@ export async function restoreBackup(backupFilename: string): Promise<void> {
const originalFilename = match[1];
const targetPath = path.join(CONTENT_DIR, originalFilename);
// Create a backup of current state first
// Read before touching anything, so a missing or unreadable backup changes
// nothing on disk.
const content = await fs.readFile(backupPath);
// Create a backup of current state first. A failed safety copy aborts the
// restore rather than being swallowed: `fs.copyFile` below would otherwise
// truncate `targetPath` before writing it, so on a full disk or a container
// killed mid-copy the live file could be lost with no snapshot to fall back
// on (#630). Only ENOENT — there is no current file to snapshot, which is
// the normal case for a deleted entry — is not an abort.
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const preRestoreBackup = `${originalFilename}.${timestamp}.pre-restore.bak`;
try {
await fs.copyFile(targetPath, path.join(backupDir, preRestoreBackup));
} catch {
// Original might not exist
// Bound them here rather than waiting for the next save: restoring twice in
// a row is exactly when these pile up, and a save may be a long way off.
await pruneBackups(backupDir, originalFilename);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
await fs.copyFile(backupPath, targetPath);
// atomicWrite rather than fs.copyFile: a copy truncates the destination
// before writing, so a failure partway through (full disk, killed
// container) leaves targetPath empty. Temp-file-then-rename means the old
// content stays in place until the new content is fully on disk.
await atomicWrite(targetPath, content);
console.log(`[Admin] 🔄 Restored ${originalFilename} from ${backupFilename}`);
}
/** Remove old backups, keeping only MAX_BACKUPS most recent. */
/**
* Remove old backups, keeping the newest MAX_BACKUPS of each kind.
*
* The two kinds are counted separately on purpose: ten ordinary saves must not
* push out the snapshot taken just before a restore, which is the only way back
* from one. That is why pre-restore files were exempt from pruning.
*
* Exempting them entirely was the bug, though. listBackups() returns them and
* the modal lists them, so every restore added a row that was never removed
* until the useful backups were off the end of the list. A cap of their own
* keeps the undo without letting it crowd out everything else.
*/
async function pruneBackups(backupDir: string, filename: string): Promise<void> {
try {
const files = await fs.readdir(backupDir);
const relevant = files
.filter((f) => f.startsWith(filename) && !f.includes('pre-restore'))
.sort();
if (relevant.length > MAX_BACKUPS) {
const toDelete = relevant.slice(0, relevant.length - MAX_BACKUPS);
for (const f of toDelete) {
await fs.unlink(path.join(backupDir, f));
const saves: string[] = [];
const preRestores: string[] = [];
for (const f of files.filter((f) => f.startsWith(filename))) {
(f.includes('pre-restore') ? preRestores : saves).push(f);
}
// The timestamp is fixed-width and lexically ordered, so sorting by name
// sorts by age.
for (const group of [saves, preRestores]) {
group.sort();
for (const old of group.slice(0, Math.max(0, group.length - MAX_BACKUPS))) {
await fs.unlink(path.join(backupDir, old));
}
}
} catch {

56
lib/atomicWrite.ts Normal file
View File

@@ -0,0 +1,56 @@
import fs from 'fs/promises';
/**
* Monotonic within the process, so two writes to the same path started in the
* same millisecond cannot land on the same temp file. Several of the copies
* this replaces used `Date.now()`, which can.
*/
let tmpCounter = 0;
/**
* Write a file atomically: unique temp file, then rename.
*
* The rename is what makes this atomic; the temp filename is what did not.
* With a constant `${filePath}.tmp`, two saves of the same file in flight at
* once shared it — the second writeFile could interleave with the first's
* rename, leaving one save's bytes published under the other's, or a truncated
* mix. A double-clicked Save in the page builder is enough.
*
* Readers matter as much as writers: `GET /api/favicon` reads its file on every
* request, so a plain writeFile would let a concurrent read see a half-written
* icon.
*
* No encoding is passed. `fs.writeFile` defaults to utf8 for a string and
* writes a Buffer verbatim, which is what every caller wants — the copies this
* replaces variously said `'utf8'`, `'utf-8'` or nothing at all.
*
* Creating the directory is the caller's business; this only writes the file.
*
* `mode`, when given, is applied to the temp file before the rename — not
* `chmod`ed on the final path afterwards. A file holding a secret (an API
* key, an auth secret) that is written world-readable and only tightened a
* moment later is briefly the wrong permission on disk, and briefly is all a
* reader running at the wrong instant needs. `rename` keeps whatever mode the
* source had, so creating the temp file with the right one from the start
* means the secret is never world-readable at all.
*/
export async function atomicWrite(
filePath: string,
content: string | Buffer,
options?: { mode?: number },
): Promise<void> {
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
try {
await fs.writeFile(
tmpPath,
content,
options?.mode !== undefined ? { mode: options.mode } : undefined,
);
await fs.rename(tmpPath, filePath);
} catch (err) {
// Do not leave litter behind a failed save. Cleanup failure is not worth
// masking the real error with.
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
}

View File

@@ -126,6 +126,22 @@ export function isAlbumReachable(
return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage'));
}
/**
* The albums this visitor may see the photographs of: every album without a
* password of its own, plus the ones already unlocked.
*
* For views that gather photographs from several albums onto one page — the
* essay layout — where there is no single album to put a password form in
* front of. A locked album is left out rather than gating the whole page: the
* visitor unlocks it at its own URL and finds it here afterwards.
*/
export function withoutLockedAlbums<T extends { id: string }>(
albums: T[],
getCookie: (name: string) => string | undefined,
): T[] {
return albums.filter((a) => isAuthenticated(a.id, getCookie, 'album'));
}
/**
* Find the SubpageConfig for a given slug.
*/

View File

@@ -10,8 +10,13 @@ import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumS
import { LOCATION_PRECISIONS, isLocationPrecision, type LocationPrecision } from '../mapPrecision';
import {
slugify,
findAlbumSlugCollisions,
resolveExifDisplay,
resolveColorMode,
clamp,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_GAP_MAX,
AppConfig,
AlbumEntryObject,
SubpageConfig,
@@ -65,11 +70,22 @@ interface RawGridOverrides {
layout?: string;
}
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
/**
* Converts raw YAML grid overrides into a typed partial GridConfig.
*
* `columns` and `gap` are clamped here, not left for the renderer: a subpage
* or album override reaches `--grid-columns` unclamped (app/[...path]/page.tsx),
* so `columns: -1` produced an invalid `repeat(-1, 1fr)` and collapsed the
* whole grid to one column with no error anywhere (#633). The cover grid had
* this already (buildCoverGridVars); this is the same treatment for the photo
* grid it was missing from.
*/
function parseGridOverrides(raw: RawGridOverrides): Partial<GridConfig> {
return {
...(raw.columns != null ? { columns: raw.columns } : {}),
...(raw.gap != null ? { gap: raw.gap } : {}),
...(raw.columns != null
? { columns: clamp(raw.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX) }
: {}),
...(raw.gap != null ? { gap: clamp(raw.gap, 0, PHOTO_GRID_GAP_MAX) } : {}),
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
...(raw.layout != null
? {
@@ -418,10 +434,53 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
const standaloneAlbums = standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id));
// Every consumer that resolves a slug takes the first match — getAlbumBySlug,
// the sitemap, findPassword — so two subpages that slugify alike leave the
// second unreachable and its password / albums resolved from the first
// (#632). slugify() folds diacritics and punctuation, so distinct names
// collide easily: "Portfolio 2024" and "Portfolio-2024" both become
// "portfolio-2024".
const subpagesBySlug = new Map<string, string[]>();
for (const sp of subpages) {
const names = subpagesBySlug.get(sp.slug) ?? [];
names.push(sp.name);
subpagesBySlug.set(sp.slug, names);
}
for (const [slug, names] of subpagesBySlug) {
if (names.length > 1) {
throw new Error(
`Subpages ${names.map((n) => `"${n}"`).join(' and ')} all produce the slug "${slug}". ` +
`Rename one so their URLs do not collide.`,
);
}
}
// Same rule applies to albums that share a route (standalone albums among
// themselves, or one subpage's albums including its sections). Only a title
// override is known here without asking Immich — an album that keeps its
// Immich name can still collide, which is what the doctor's equivalent
// check, run against the live album list, is for.
const checkAlbumOverrideCollisions = (context: string, ids: string[]) => {
const named = ids
.map((id) => ({ id, name: albumOverrides[id] }))
.filter((a): a is { id: string; name: string } => !!a.name);
for (const { slug, albums } of findAlbumSlugCollisions(named)) {
throw new Error(
`${context}: albums titled ${albums.map((a) => `"${a.name}"`).join(' and ')} all produce ` +
`the slug "${slug}". Give one a different title override.`,
);
}
};
checkAlbumOverrideCollisions('gallery.yaml albums', standaloneAlbums);
for (const sp of subpages) {
checkAlbumOverrideCollisions(`subpage "${sp.name}"`, sp.albumIds);
}
return {
albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
standaloneAlbums,
subpages,
albumOverrides,
albumDescriptions,
@@ -587,8 +646,11 @@ export function getConfig(): AppConfig {
exifOnHover: settings.exifOnHover !== false,
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
grid: {
columns: settings.grid?.columns ?? 3,
gap: settings.grid?.gap ?? 12,
columns:
settings.grid?.columns != null
? clamp(settings.grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX)
: 3,
gap: settings.grid?.gap != null ? clamp(settings.grid.gap, 0, PHOTO_GRID_GAP_MAX) : 12,
aspectRatio: settings.grid?.aspectRatio ?? '1',
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
? settings.grid!.layout

View File

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

View File

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

View File

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

View File

@@ -25,13 +25,32 @@ export interface Env {
SITE_URL?: string;
}
/**
* Strip a trailing slash (or several) from a URL, after confirming it parses
* at all. Deliberately not `/api`-aware, unlike install.ts's
* normalizeApiBase(): this is the "base URL, no /api" shape getConfig()
* expects (it appends /api itself unless the value already ends with one),
* and it's what the install wizard's URL field pre-fills from — appending
* /api here would mean re-opening the wizard shows a path nobody typed.
*
* Exported so lib/install.ts can apply the same normalisation to
* content/install.json's apiUrl, which used to skip it entirely: a trailing
* slash there produced IMMICH_API_URL//api and every request 404ed (#634).
*/
export function normalizeApiUrl(raw: string): string {
try {
return new URL(raw).toString().replace(/\/+$/, '');
} catch {
return '';
}
}
function parseEnv(): Env {
const urlRaw = process.env.IMMICH_API_URL;
let apiUrl = '';
if (urlRaw) {
try {
apiUrl = new URL(urlRaw).toString().replace(/\/+$/, '');
} catch {
apiUrl = normalizeApiUrl(urlRaw);
if (!apiUrl) {
console.warn('⚠️ IMMICH_API_URL is invalid, falling back to empty string for setup.');
}
}

View File

@@ -19,6 +19,8 @@ export const de: Dictionary = {
map: 'Karte',
journal: 'Journal',
skipToContent: 'Zum Inhalt springen',
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
},
common: {
@@ -34,6 +36,11 @@ export const de: Dictionary = {
prevAlbumAria: (name: string) => `Vorheriges Album: ${name}`,
nextAlbumAria: (name: string) => `Nächstes Album: ${name}`,
albumNavAria: 'Albumnavigation',
prevEntry: 'Vorheriger Beitrag',
nextEntry: 'Nächster Beitrag',
prevEntryAria: (title: string) => `Vorheriger Beitrag: ${title}`,
nextEntryAria: (title: string) => `Nächster Beitrag: ${title}`,
entryNavAria: 'Journal-Navigation',
loadingGallery: 'Galerie wird geladen',
loadingPhotos: 'Fotos werden geladen',
},
@@ -91,6 +98,8 @@ export const de: Dictionary = {
collectionKicker: (index: string) => `${index} — Sammlung`,
sectionsNav: 'Abschnitte',
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
nextSubpage: 'Weiter',
nextSubpageAria: (name: string) => `Weiter: ${name}`,
},
journal: {

View File

@@ -19,6 +19,8 @@ export const en = {
map: 'Map',
journal: 'Journal',
skipToContent: 'Skip to content',
openMenu: 'Open menu',
closeMenu: 'Close menu',
},
common: {
@@ -34,6 +36,11 @@ export const en = {
prevAlbumAria: (name: string) => `Previous album: ${name}`,
nextAlbumAria: (name: string) => `Next album: ${name}`,
albumNavAria: 'Album navigation',
prevEntry: 'Previous entry',
nextEntry: 'Next entry',
prevEntryAria: (title: string) => `Previous entry: ${title}`,
nextEntryAria: (title: string) => `Next entry: ${title}`,
entryNavAria: 'Journal entry navigation',
loadingGallery: 'Loading gallery',
loadingPhotos: 'Loading photos',
},
@@ -91,6 +98,8 @@ export const en = {
collectionKicker: (index: string) => `${index} — Collection`,
sectionsNav: 'Sections',
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
nextSubpage: 'Next',
nextSubpageAria: (name: string) => `Next: ${name}`,
},
journal: {

View File

@@ -38,6 +38,19 @@ function smaller(a: ImageSize, b: ImageSize): ImageSize {
* Previously `?size=` won unconditionally, which made this function unreachable
* for any URL the app generates, since lib/urls.ts always writes `?size=`.
*/
/**
* Ceiling this function will ever return, regardless of what the client asks
* for. `/api/image` treats the opaque token as the whole capability check —
* holding one means you saw the page it was rendered on — which is right for
* a preview but not for the un-downsampled file. Only `/api/download`
* verifies the album allowlist, `download: true` and every password gate
* before handing out an original; nothing this route does replaces that.
* `?size=original` or a large enough `?w=` alone used to reach `original`
* unopposed, since the pairwise `smaller()` check below only ever ran when
* *both* parameters were present.
*/
const MAX_SIZE: ImageSize = 'preview';
export function resolveImageSize(sizeParam: string | null, widthParam: string | null): ImageSize {
const explicit =
sizeParam && VALID_SIZES.includes(sizeParam as ImageSize) ? (sizeParam as ImageSize) : null;
@@ -45,6 +58,7 @@ export function resolveImageSize(sizeParam: string | null, widthParam: string |
const w = widthParam ? parseInt(widthParam, 10) : NaN;
const fromWidth = !isNaN(w) && w > 0 ? widthToSize(w) : null;
if (explicit && fromWidth) return smaller(explicit, fromWidth);
return explicit ?? fromWidth ?? 'preview';
const resolved =
explicit && fromWidth ? smaller(explicit, fromWidth) : (explicit ?? fromWidth ?? 'preview');
return smaller(resolved, MAX_SIZE);
}

View File

@@ -5,11 +5,27 @@
*
* API key never leaves the server — all client-facing image
* requests go through our proxy route.
*
* What this file is about is *what* Folio asks Immich for: albums, subpages,
* assets, EXIF. How the asking happens lives in ./immichTransport, and when a
* cached answer may still be served in ./immichCache (#610).
*/
import { getConfig, albumSlug, normalizeSlug, type SubpageConfig } from './config';
import { cache } from './cache';
import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
import { ImmichUnavailableError, isTimeout, requestJson } from './immichTransport';
import {
MISSING,
type Missing,
cacheSet as cacheSetWithStale,
staleOrThrow as serveStaleOrThrow,
staleOrMissing as serveStaleOrMissing,
} from './immichCache';
// Part of this module's public surface since before the split; six call sites
// import it from here.
export { ImmichUnavailableError };
// ── Types ──────────────────────────────────────────────────────
@@ -69,53 +85,6 @@ export interface ImmichExifInfo {
export type ImageSize = 'thumbnail' | 'preview' | 'original';
/**
* Thrown when Immich could not answer: transport failure, an error status, or
* a non-JSON body where JSON was requested.
*
* This is deliberately distinct from a `null` return, which means Immich *did*
* answer and said the resource does not exist. Conflating the two made every
* album URL render a hard 404 while Immich was down — including albums that
* exist — because the page calls `notFound()` on a null album. Only 404/410
* are treated as "gone"; everything else is an outage and must surface as one.
*/
/**
* Cache sentinel for "Immich answered, and the resource does not exist".
*
* The cache cannot tell a miss from a stored null — both read back as null — so
* absence is recorded as a distinct object identity instead.
*
* Only definitive 404/410 answers are stored. An outage throws
* `ImmichUnavailableError` and is never cached: pinning one would keep the
* gallery broken long after Immich recovered.
*
* Stored under the normal `cacheTtl` rather than a shorter negative TTL. A 404
* from Immich is as authoritative as a 200, and both invalidation paths
* (the Immich webhook and the admin panel's save/reload) clear it immediately —
* so a corrected asset ID takes effect at once rather than waiting out a TTL.
*/
const MISSING = Object.freeze({ __immichMissing: true });
type Missing = typeof MISSING;
/**
* `AbortSignal.timeout()` rejects with a `TimeoutError`; an explicit
* `controller.abort()` rejects with an `AbortError`. Both mean we gave up
* waiting, and both arrive as a plain DOMException.
*/
function isTimeout(error: unknown): boolean {
return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
}
export class ImmichUnavailableError extends Error {
readonly status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'ImmichUnavailableError';
this.status = status;
}
}
/** Enriched subpage with album metadata (for rendering cards). */
export interface SubpageSummary {
name: string;
@@ -166,93 +135,39 @@ class ImmichClient {
/** Cache write that carries the configured stale window. */
private cacheSet<T>(key: string, data: T): void {
cache.set(key, data, this.config.cacheTtl, this.config.staleMaxAge);
cacheSetWithStale(key, data, this.config.cacheTtl, this.config.staleMaxAge);
}
/** See ./immichCache — the policy lives there, testable on its own. */
private staleOrThrow<T>(cacheKey: string, error: unknown, label: string): T {
return serveStaleOrThrow<T>(cacheKey, error, label);
}
/** For keys that may hold MISSING — a single album or asset. See ./immichCache. */
private staleOrMissing<T>(cacheKey: string, error: unknown, label: string): T | null {
return serveStaleOrMissing<T>(cacheKey, error, label);
}
/**
* Last resort when Immich is unavailable: hand back the most recent known
* good answer rather than failing the page. Only successes ever reach the
* cache, so this cannot resurrect an outage — and past staleMaxAge the entry
* is gone and the error propagates as before.
* One JSON request to Immich, with the credentials check in front of it.
*
* The request itself lives in ./immichTransport; what belongs here is the
* decision not to make one at all, which is a question about this
* deployment rather than about HTTP.
*/
private staleOrThrow<T>(cacheKey: string, error: unknown, label: string): T {
if (error instanceof ImmichUnavailableError) {
const stale = cache.getStale<T>(cacheKey);
if (stale !== null) {
console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`);
return stale;
}
}
throw error;
}
private async request<T>(endpoint: string, body?: unknown): Promise<T | null> {
if (!this.hasCredentials) {
this.warnNoCredentials(endpoint);
return null;
}
const url = `${this.config.immich.apiUrl}${endpoint}`;
let res: Response;
try {
res = await fetch(url, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'x-api-key': this.config.immich.apiKey,
Accept: 'application/json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
// JSON payloads are small, so one budget can cover the whole exchange.
// Without it the only ceiling is undici's default, measured in minutes.
signal: AbortSignal.timeout(this.config.immichTimeoutMs),
});
} catch (error) {
console.error(`[Immich] Failed to reach ${url}:`, error);
throw new ImmichUnavailableError(
isTimeout(error)
? `Immich did not respond within ${this.config.immichTimeoutMs}ms for ${endpoint}`
: `Cannot reach Immich for ${endpoint}`,
);
}
if (!res.ok) {
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
// Only "gone" means gone. A 5xx, a rate limit, or a rejected API key all
// mean Immich cannot serve us right now — rendering those as a missing
// album would tell visitors and crawlers the content no longer exists.
if (res.status !== 404 && res.status !== 410) {
throw new ImmichUnavailableError(
`Immich returned ${res.status} ${res.statusText} for ${endpoint}`,
res.status,
);
}
return null;
}
const contentType = res.headers.get('Content-Type') || '';
if (!contentType.includes('application/json')) {
// We always send Accept: application/json. Anything else is a gateway or
// proxy error page, not a valid answer about the resource.
throw new ImmichUnavailableError(
`Immich returned non-JSON (${contentType || 'no Content-Type'}) for ${endpoint}`,
);
}
try {
return (await res.json()) as T;
} catch (error) {
// The timeout also covers the body read, so an abort can surface here.
if (isTimeout(error)) {
console.error(`[Immich] Timed out reading ${url}`);
throw new ImmichUnavailableError(
`Immich did not respond within ${this.config.immichTimeoutMs}ms for ${endpoint}`,
);
}
console.error(`[Immich] Malformed JSON from ${url}:`, error);
throw new ImmichUnavailableError(`Immich returned malformed JSON for ${endpoint}`);
}
return requestJson<T>({
apiUrl: this.config.immich.apiUrl,
apiKey: this.config.immich.apiKey,
timeoutMs: this.config.immichTimeoutMs,
endpoint,
body,
});
}
/**
@@ -293,6 +208,9 @@ class ImmichClient {
if (!res.ok && res.status !== 206) {
console.error(`[Immich] Failed to stream video ${assetId}: ${res.status}`);
// Neither branch below reads the body — an unconsumed one keeps its
// socket out of undici's pool until GC finalises it (#635).
await res.body?.cancel();
if (res.status !== 404 && res.status !== 410) {
throw new ImmichUnavailableError(
`Immich returned ${res.status} streaming video ${assetId}`,
@@ -362,6 +280,9 @@ class ImmichClient {
if (!res.ok) {
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
// Neither branch below reads the body — an unconsumed one keeps its
// socket out of undici's pool until GC finalises it (#635).
await res.body?.cancel();
if (res.status !== 404 && res.status !== 410) {
throw new ImmichUnavailableError(
`Immich returned ${res.status} streaming asset ${assetId}`,
@@ -404,9 +325,12 @@ class ImmichClient {
*/
async getAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
const cacheKey = 'albums-list';
if (forceFresh) {
cache.delete(cacheKey);
} else {
// forceFresh only skips the read below; it must not delete the entry
// outright. Deleting first meant a request that then failed left every
// later visitor with nothing to fall back to — staleOrThrow() below reads
// 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;
}
@@ -656,9 +580,10 @@ class ImmichClient {
}
const cacheKey = `album-${albumId}`;
if (forceFresh) {
cache.delete(cacheKey);
} else {
// See getAlbums(): forceFresh skips the cached read, it does not delete
// the entry, so a fetch that then fails still has something to fall back
// to via staleOrThrow() below.
if (!forceFresh) {
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
}
@@ -676,7 +601,7 @@ class ImmichClient {
this.fetchAlbumAssets(albumId),
]);
if (!album) {
cache.set(cacheKey, MISSING, this.config.cacheTtl);
this.cacheSet(cacheKey, MISSING);
return null;
}
@@ -711,7 +636,7 @@ class ImmichClient {
this.cacheSet(cacheKey, album);
return album;
} catch (error) {
return this.staleOrThrow<ImmichAlbum>(cacheKey, error, `album ${albumId}`);
return this.staleOrMissing<ImmichAlbum>(cacheKey, error, `album ${albumId}`);
} finally {
this.pendingAlbumPromises.delete(albumId);
}
@@ -723,7 +648,12 @@ class ImmichClient {
/**
* Find an album by its URL slug.
* If subpageSlug is provided, only search within that subpage's albums.
*
* The search set follows the route, never the whole allowlist: with a
* subpageSlug it is that subpage's albums, without one it is the standalone
* albums. `config.albums` is the union of both, so searching it for a
* top-level slug would answer for an album whose only route is a subpage —
* past that subpage's password, and past `enabled: false`.
*/
async getAlbumBySlug(
slug: string,
@@ -732,14 +662,18 @@ class ImmichClient {
): Promise<ImmichAlbum | null> {
const albums = await this.getAlbums(forceFresh);
let searchSet = albums;
let routeIds: Set<string>;
if (subpageSlug) {
const wantedSubpage = normalizeSlug(subpageSlug);
const subpage = this.config.subpages.find((sp) => sp.slug === wantedSubpage);
const subpage = this.config.subpages.find(
(sp) => sp.slug === wantedSubpage && sp.enabled !== false,
);
if (!subpage) return null;
const subpageIds = new Set(subpage.albumIds);
searchSet = albums.filter((a) => subpageIds.has(a.id));
routeIds = new Set(subpage.albumIds);
} else {
routeIds = new Set(this.config.standaloneAlbums);
}
const searchSet = albums.filter((a) => routeIds.has(a.id));
const wanted = normalizeSlug(slug);
const match = searchSet.find((a) => a.slug === wanted);
@@ -777,14 +711,14 @@ class ImmichClient {
// The homepage looks up every gallery.yaml hero ID on each render
// (app/page.tsx), and those pages are force-dynamic — so a hero photo
// deleted from Immich otherwise costs an upstream 404 every time.
cache.set(cacheKey, MISSING, this.config.cacheTtl);
this.cacheSet(cacheKey, MISSING);
return null;
}
this.cacheSet(cacheKey, asset);
return asset;
} catch (error) {
return this.staleOrThrow<ImmichAsset>(cacheKey, error, `asset ${assetId}`);
return this.staleOrMissing<ImmichAsset>(cacheKey, error, `asset ${assetId}`);
} finally {
this.pendingAssetPromises.delete(assetId);
}

91
lib/immichCache.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* When a cached answer from Immich may still be served — the policy on its own,
* separated from the client that applies it (#610).
*
* This governs how quickly the public site reflects a change made in Immich,
* and what visitors see while Immich is down. Inside the client it was only
* reachable through HTTP mocks; here it is a plain function over a cache and an
* error.
*/
import { cache } from './cache';
import { ImmichUnavailableError } from './immichTransport';
/**
* Cache sentinel for "Immich answered, and the resource does not exist".
*
* The cache cannot tell a miss from a stored null — both read back as null — so
* absence is recorded as a distinct object identity instead.
*
* Only definitive 404/410 answers are stored. An outage throws
* `ImmichUnavailableError` and is never cached: pinning one would keep the
* gallery broken long after Immich recovered.
*
* Stored under the normal `cacheTtl`, and — unlike what the 2026-08-05
* resilience plan says — **inside** the stale window, deliberately. A 404 is as
* authoritative an answer as a 200, so during an outage "still missing" is the
* last known good answer, exactly like a stale album is. The alternative is
* worse than it sounds: the homepage looks up every hero ID in one
* `Promise.all` with no catch, so a single deleted hero photo whose "missing"
* had expired would take the whole homepage down for the length of the outage,
* while every other image could have been served stale (#626).
*
* Both invalidation paths (the Immich webhook and the admin panel's
* save/reload) clear it immediately, so a corrected ID takes effect at once.
*/
export const MISSING = Object.freeze({ __immichMissing: true });
export type Missing = typeof MISSING;
/** Cache write carrying the configured stale window. */
export function cacheSet<T>(key: string, data: T, ttlMs: number, staleMaxAgeMs: number): void {
cache.set(key, data, ttlMs, staleMaxAgeMs);
}
/**
* Last resort when Immich is unavailable, for keys that only ever hold real
* data: hand back the most recent known good answer rather than failing the
* page.
*
* It answers only ImmichUnavailableError. Any other error means we asked the
* wrong question, not that the answer is unavailable, and serving something
* stale would hide a real bug behind yesterday's data.
*
* It will not cast the MISSING sentinel into a `T`. That cast is what #626
* was: a sentinel handed back as an album or an asset is a truthy object with
* no fields, so every `if (!asset)` guard passes and the page answers 500.
* Keys that can hold the sentinel go through staleOrMissing instead; here,
* meeting one means there is nothing usable, and the outage propagates.
*/
export function staleOrThrow<T>(cacheKey: string, error: unknown, label: string): T {
if (error instanceof ImmichUnavailableError) {
const stale = cache.getStale<T | Missing>(cacheKey);
if (stale !== null && stale !== MISSING) {
console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`);
return stale as T;
}
}
throw error;
}
/**
* The same fallback for keys that may hold the MISSING sentinel — a single
* album or a single asset.
*
* A stale "missing" comes back as `null`, which is what the caller would have
* got from Immich itself: the last authoritative answer was that the resource
* does not exist, and every caller already handles `null` that way.
*/
export function staleOrMissing<T>(cacheKey: string, error: unknown, label: string): T | null {
if (error instanceof ImmichUnavailableError) {
const stale = cache.getStale<T | Missing>(cacheKey);
if (stale === MISSING) {
console.warn(`[Immich] ⚠️ Upstream unavailable — ${label} was last known missing`);
return null;
}
if (stale !== null) {
console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`);
return stale as T;
}
}
throw error;
}

130
lib/immichTransport.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* How Folio talks to Immich over HTTP — and nothing about what it asks for.
*
* Split out of lib/immich.ts (#610), where transport, caching and domain logic
* shared one 835-line class. Everything here is a plain function over explicit
* arguments: no config lookup, no cache, no client state. That is what makes
* the rules below testable without standing up a client.
*/
/**
* `AbortSignal.timeout()` rejects with a `TimeoutError`; an explicit
* `controller.abort()` rejects with an `AbortError`. Both mean we gave up
* waiting, and both arrive as a plain DOMException.
*/
export function isTimeout(error: unknown): boolean {
return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
}
/**
* Thrown when Immich could not answer: transport failure, an error status, or
* a non-JSON body where JSON was requested.
*
* This is deliberately distinct from a `null` return, which means Immich *did*
* answer and said the resource does not exist. Conflating the two made every
* album URL render a hard 404 while Immich was down — including albums that
* exist — because the page calls `notFound()` on a null album. Only 404/410
* are treated as "gone"; everything else is an outage and must surface as one.
*/
export class ImmichUnavailableError extends Error {
readonly status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'ImmichUnavailableError';
this.status = status;
}
}
export interface ImmichRequestOptions {
apiUrl: string;
apiKey: string;
timeoutMs: number;
/** Path appended to apiUrl, e.g. `/albums`. */
endpoint: string;
/** Present for a POST, absent for a GET. */
body?: unknown;
}
/**
* One JSON request to Immich.
*
* Returns `null` only when the resource is genuinely gone (404 or 410) and
* throws ImmichUnavailableError for everything else. That distinction is the
* point of this function: a 5xx, a rate limit or a rejected API key all mean
* Immich cannot serve us *right now*, and rendering those as a missing album
* would tell visitors and crawlers the content no longer exists.
*/
export async function requestJson<T>({
apiUrl,
apiKey,
timeoutMs,
endpoint,
body,
}: ImmichRequestOptions): Promise<T | null> {
const url = `${apiUrl}${endpoint}`;
let res: Response;
try {
res = await fetch(url, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'x-api-key': apiKey,
Accept: 'application/json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
// JSON payloads are small, so one budget can cover the whole exchange.
// Without it the only ceiling is undici's default, measured in minutes.
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
console.error(`[Immich] Failed to reach ${url}:`, error);
throw new ImmichUnavailableError(
isTimeout(error)
? `Immich did not respond within ${timeoutMs}ms for ${endpoint}`
: `Cannot reach Immich for ${endpoint}`,
);
}
if (!res.ok) {
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
// Neither branch below reads the body. Under undici an unconsumed body
// keeps its socket out of the pool until GC finalises it, so a proxy
// answering every request with an error accumulates orphaned sockets for
// as long as the fault lasts (#635).
await res.body?.cancel();
if (res.status !== 404 && res.status !== 410) {
throw new ImmichUnavailableError(
`Immich returned ${res.status} ${res.statusText} for ${endpoint}`,
res.status,
);
}
return null;
}
const contentType = res.headers.get('Content-Type') || '';
if (!contentType.includes('application/json')) {
// We always send Accept: application/json. Anything else is a gateway or
// proxy error page, not a valid answer about the resource — and, same as
// above, a body neither read here (#635).
await res.body?.cancel();
throw new ImmichUnavailableError(
`Immich returned non-JSON (${contentType || 'no Content-Type'}) for ${endpoint}`,
);
}
try {
return (await res.json()) as T;
} catch (error) {
// The timeout also covers the body read, so an abort can surface here.
if (isTimeout(error)) {
console.error(`[Immich] Timed out reading ${url}`);
throw new ImmichUnavailableError(
`Immich did not respond within ${timeoutMs}ms for ${endpoint}`,
);
}
console.error(`[Immich] Malformed JSON from ${url}:`, error);
throw new ImmichUnavailableError(`Immich returned malformed JSON for ${endpoint}`);
}
}

View File

@@ -21,7 +21,8 @@ import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import yaml from 'js-yaml';
import { env } from './env';
import { atomicWrite } from './atomicWrite';
import { env, normalizeApiUrl } from './env';
import { generateScryptHash } from './password';
import type { GalleryYaml, SettingsYaml } from './config/schema';
import { DEFAULT_PRESET } from './config/theme';
@@ -164,8 +165,13 @@ function readInstallFile(): InstallFileData {
*/
export function getInstallCredentials(): InstallCredentials {
const file = readInstallFile();
const apiUrl = env.IMMICH_API_URL || file.apiUrl || '';
return {
apiUrl: env.IMMICH_API_URL || file.apiUrl || '',
// Normalised on every read, not only when completeInstall writes it, so
// an install.json written before this fix heals without re-running the
// wizard. env.ts already normalises IMMICH_API_URL, so this is a no-op
// for that source and only does real work for file.apiUrl (#634).
apiUrl: apiUrl ? normalizeApiUrl(apiUrl) || apiUrl : '',
apiKey: env.IMMICH_API_KEY || file.apiKey || '',
authSecret: env.AUTH_SECRET || file.authSecret || '',
adminPassword: env.ADMIN_PASSWORD || file.adminPassword || '',
@@ -223,16 +229,10 @@ export interface InstallInput {
adminPassword?: string;
}
/** Write a file atomically: unique temp file, then rename. */
async function atomicWrite(filePath: string, content: string): Promise<void> {
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.promises.writeFile(tmpPath, content, 'utf8');
await fs.promises.rename(tmpPath, filePath);
} catch (err) {
await fs.promises.unlink(tmpPath).catch(() => {});
throw err;
}
/** Which of the two config files completeInstall actually wrote. */
export interface CompleteInstallResult {
galleryWritten: boolean;
settingsWritten: boolean;
}
/**
@@ -242,7 +242,7 @@ async function atomicWrite(filePath: string, content: string): Promise<void> {
* the owner can fill in later via /admin), settings.yaml, and install.json.
* Runs before caches are invalidated, so the caller owns that step.
*/
export async function completeInstall(input: InstallInput): Promise<void> {
export async function completeInstall(input: InstallInput): Promise<CompleteInstallResult> {
const dir = installContentDir();
await fs.promises.mkdir(dir, { recursive: true });
@@ -261,8 +261,15 @@ export async function completeInstall(input: InstallInput): Promise<void> {
theme: { preset: input.theme || DEFAULT_PRESET },
};
// Stored normalised, not raw: the route verifies the connection against
// normalizeApiBase(apiUrl), which strips a trailing slash, but this used
// to write input.apiUrl.trim() unchanged, so "https://host/api/" passed
// the ping and then every request after install went to
// ".../api//api/albums" and 404ed (#634).
const normalizedApiUrl = normalizeApiUrl(input.apiUrl.trim()) || input.apiUrl.trim();
const data: InstallFileData = {
apiUrl: input.apiUrl.trim(),
apiUrl: normalizedApiUrl,
apiKey: input.apiKey.trim(),
authSecret: crypto.randomBytes(32).toString('hex'),
// Hashed, not stored as typed: the API key and the site secret have to be
@@ -280,20 +287,32 @@ export async function completeInstall(input: InstallInput): Promise<void> {
const galleryPath = path.join(dir, 'gallery.yaml');
const settingsPath = path.join(dir, 'settings.yaml');
await atomicWrite(galleryPath, header('Gallery Structure') + yaml.dump(gallery, dumpOptions));
// Only create gallery.yaml if it does not already exist yet, same as
// settings.yaml below. isInstalled() is false whenever Immich credentials
// fail to resolve — a bad IMMICH_API_URL, a corrupt install.json — whether
// or not gallery.yaml exists, so re-running the wizard after that is not
// evidence gallery.yaml needs replacing. Every subpage, section, password,
// assetOrder and per-album override a site has accumulated used to be
// overwritten unconditionally with this two-key skeleton (#627).
const galleryWritten = !fs.existsSync(galleryPath);
if (galleryWritten) {
await atomicWrite(galleryPath, header('Gallery Structure') + yaml.dump(gallery, dumpOptions));
}
// Only create settings.yaml if it does not already exist — a deployment that
// brings its own customised settings.yaml (but is missing gallery.yaml) must
// not have it silently replaced with the three-key skeleton the wizard writes.
if (!fs.existsSync(settingsPath)) {
const settingsWritten = !fs.existsSync(settingsPath);
if (settingsWritten) {
await atomicWrite(settingsPath, header('Site Settings') + yaml.dump(settings, dumpOptions));
}
await atomicWrite(path.join(dir, INSTALL_FILENAME), `${JSON.stringify(data, null, 2)}\n`);
try {
await fs.promises.chmod(path.join(dir, INSTALL_FILENAME), 0o600);
} catch {
// chmod is a no-op on Windows; on Linux it prevents world-readable secrets.
}
// Written 0600 from the start, like the setup token below — not created
// world-readable and tightened afterwards, which briefly left the API key
// and auth secret readable by anyone on the box (GHSA-w293-x8pc-j4cv).
// `mode` is a no-op on Windows, same as the chmod it replaces was.
await atomicWrite(path.join(dir, INSTALL_FILENAME), `${JSON.stringify(data, null, 2)}\n`, {
mode: 0o600,
});
// The token exists to gate a setup that has not happened yet. Leaving it on
// disk would keep a valid credential lying around for a door that is now
@@ -307,4 +326,6 @@ export async function completeInstall(input: InstallInput): Promise<void> {
// Drop the in-process cache so the next isInstalled()/getInstallCredentials()
// call reads the freshly written file.
cachedInstallFile = null;
return { galleryWritten, settingsWritten };
}

View File

@@ -117,9 +117,14 @@ export function renderInlineMarkdown(text: string): string {
// then renders as `*<em>bold </em>and italic<em> here</em>*`. The input is
// Markdown written by an authenticated admin, never visitor input, so the
// worst case is an author slowing down their own page.
html = html.replace(/(\*\*|__)(.*?)\1/g, '<strong>$2</strong>');
// Italic: *text* or _text_
html = html.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
// `**bold**` works anywhere; `__bold__` only outside a word, matching
// CommonMark's intraword rule for `_` — otherwise `my_file_name` round-trips
// through `<em>` and comes back with asterisks in the middle of a word.
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(?<![\w])__(.*?)__(?![\w])/g, '<strong>$1</strong>');
// 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) => {
const trimmed = url.trim();
@@ -129,6 +134,82 @@ export function renderInlineMarkdown(text: string): string {
return html;
}
/**
* Inverse of renderInlineMarkdown: walks the `<strong>`/`<em>`/`<a href>` tags
* it produces (and only those — anything else is dropped, keeping its
* content, matching what the previous flat regex did for unknown tags) back
* into their Markdown source, recursing so a link around bold text, or bold
* text around a link, both come back correctly. Entities are decoded once at
* the end, since every text run and the `href` value are the only places
* they appear — the markdown syntax this function adds is never itself
* entity-escaped.
*/
function inlineHtmlToMarkdownRaw(html: string): string {
let out = '';
let i = 0;
while (i < html.length) {
if (html[i] !== '<') {
out += html[i];
i++;
continue;
}
const openMatch = /^<(\w+)([^>]*)>/.exec(html.slice(i));
if (!openMatch) {
out += html[i];
i++;
continue;
}
const [full, tagName, attrs] = openMatch;
// Find this tag's matching close, tracking nested opens of the same tag.
const closeTag = `</${tagName}>`;
let depth = 1;
let scan = i + full.length;
let closeAt = -1;
while (scan < html.length) {
const nextClose = html.indexOf(closeTag, scan);
if (nextClose === -1) break;
const nextOpen = html.indexOf(`<${tagName}`, scan);
if (nextOpen !== -1 && nextOpen < nextClose) {
depth++;
scan = nextOpen + 1;
} else {
depth--;
scan = nextClose + closeTag.length;
if (depth === 0) {
closeAt = nextClose;
break;
}
}
}
if (closeAt === -1) {
// Unmatched tag: not something this renderer produces, pass it through
// as literal text rather than losing it.
out += html[i];
i++;
continue;
}
const innerMd = inlineHtmlToMarkdownRaw(html.slice(i + full.length, closeAt));
if (tagName === 'strong' || tagName === 'b') out += `**${innerMd}**`;
else if (tagName === 'em' || tagName === 'i') out += `*${innerMd}*`;
else if (tagName === 'a') {
const href = /\shref="([^"]*)"/.exec(attrs)?.[1] ?? '';
out += `[${innerMd}](${href})`;
} else {
out += innerMd;
}
i = closeAt + closeTag.length;
}
return out;
}
export function inlineHtmlToMarkdown(html: string): string {
return decodeHtmlEntities(inlineHtmlToMarkdownRaw(html));
}
const isWhitespace = (char: string) => char.trim() === '';
/**
@@ -346,32 +427,27 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
break;
}
case 'paragraph': {
const text = block.html.replace(/<[^>]+>/g, (tag) => {
if (tag.startsWith('<strong>') || tag.startsWith('<b>')) return '**';
if (tag.startsWith('</strong>') || tag.startsWith('</b>')) return '**';
if (tag.startsWith('<em>') || tag.startsWith('<i>')) return '*';
if (tag.startsWith('</em>') || tag.startsWith('</i>')) return '*';
return '';
});
lines.push(decodeHtmlEntities(text));
lines.push(inlineHtmlToMarkdown(block.html));
break;
}
case 'quote': {
const authorSuffix = block.author ? ` -- ${block.author}` : '';
const text = decodeHtmlEntities(block.text.replace(/[\r\n]+/g, ' '));
const text = inlineHtmlToMarkdown(block.text.replace(/[\r\n]+/g, ' '));
lines.push(`> ${text}${authorSuffix}`);
break;
}
case 'photo': {
const layoutSuffix = block.layout !== 'contained' ? `:${block.layout}` : '';
const caption = block.caption
? decodeHtmlEntities(block.caption.replace(/[\r\n]+/g, ' '))
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' '))
: '';
lines.push(`![${block.assetId}${layoutSuffix}](${caption})`);
break;
}
case 'photo-pair': {
const caption = block.caption ? block.caption.replace(/[\r\n]+/g, ' ') : '';
const caption = block.caption
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' '))
: '';
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
break;
}

57
lib/journalNav.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Sequential journal navigation — the "previous / next entry" pair at the foot
* of a journal entry, mirroring `albumNav.ts` (#483) for the journal (#591).
*
* `siblings` must already be filtered to what the current visitor may see —
* a draft, or a password-protected entry they have not unlocked, must never
* appear here even as a name-only link (GHSA-fvgv-97g3-wjr7 was exactly this
* class of leak for the index and page metadata).
*
* Neighbours are not wrapped around, for the same reason as albums: reaching
* the end of the journal should feel like an ending, not send the reader back
* to its start with no sign that they have been round.
*/
export interface JournalNavCandidate {
slug: string;
title: string;
}
export interface JournalNavLink {
href: string;
title: string;
}
export interface JournalNavPair {
prev?: JournalNavLink;
next?: JournalNavLink;
}
/**
* The neighbours of `currentSlug` within `siblings`, in that list's own order
* (publication order, newest first, same as `/journal` and `listJournalEntries()`).
*
* Returns an empty pair when the entry is not in the list, or when it is the
* only one — both cases mean there is nothing to offer, and the caller renders
* nothing.
*/
export function journalNeighbours(
siblings: readonly JournalNavCandidate[],
currentSlug: string,
): JournalNavPair {
const index = siblings.findIndex((e) => e.slug === currentSlug);
if (index === -1 || siblings.length < 2) return {};
const link = (entry: JournalNavCandidate): JournalNavLink => ({
href: `/journal/${entry.slug}`,
title: entry.title,
});
const previous = index > 0 ? siblings[index - 1] : undefined;
const following = index < siblings.length - 1 ? siblings[index + 1] : undefined;
return {
...(previous ? { prev: link(previous) } : {}),
...(following ? { next: link(following) } : {}),
};
}

View File

@@ -75,7 +75,10 @@ export async function getMapData(): Promise<MapLocation[]> {
for (const asset of album.assets) {
const exif = asset.exifInfo;
if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
// A falsiness check here would drop a coordinate of exactly 0 — the
// equator or the prime meridian — as if it were absent (#635).
if (exif?.latitude == null || exif?.longitude == null || !exif?.city || !exif?.country)
continue;
const key = `${exif.city}|${exif.country}`;
let byAlbum = buckets.get(key);

View File

@@ -1,14 +1,21 @@
/**
* The `#photo-N` permalink shared by the photo grid and the lightbox.
* Photo permalinks for the grid and the lightbox.
*
* The grid writes and restores it; the lightbox offers a button that copies it
* (#478). The format lives here so the two cannot drift apart.
* `?photo=<assetId>` (assetId is the opaque per-asset token PhotoItem.id
* already carries — see encodeAssetId) is the current format: a query
* parameter, not a hash, because a hash never reaches the server. A hash-only
* permalink got no OG preview card, and it named a *position* in the album
* rather than a photo — reordering the album silently repointed every shared
* link, and so did deleting a photo (#588).
*
* The link is positional, as `gallery.yaml.example` documents: it names a
* place in the album, not an asset, so reordering an album moves where a
* shared link lands.
* `#photo-N` (1-indexed, matching what `gallery.yaml.example` used to
* document) is the format this replaces. Parsing it stays here indefinitely:
* public instances have been sharing those links for months, and this module
* is where the grid and the lightbox both read from, so it cannot drift.
*/
const PHOTO_QUERY_PARAM = 'photo';
/** Parse `#photo-N`. Returns a 0-based index, or null if it is not one. */
export function parsePhotoHash(hash: string): number | null {
const match = hash.match(/^#photo-(\d+)$/);
@@ -17,20 +24,40 @@ export function parsePhotoHash(hash: string): number | null {
return index >= 0 ? index : null;
}
/** The hash for a 0-based index, 1-indexed for the reader. */
/** The legacy hash for a 0-based index, 1-indexed for the reader. */
export function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`;
}
/** Parse `?photo=<assetId>`. Returns the token, or null if it is absent. */
export function parsePhotoQuery(search: string): string | null {
return new URLSearchParams(search).get(PHOTO_QUERY_PARAM);
}
/**
* An absolute permalink to one photo.
* `search`, with `photo` set to `assetId` (or removed, when `assetId` is
* null) and every other query parameter left as it was.
*/
export function buildPhotoQuery(search: string, assetId: string | null): string {
const params = new URLSearchParams(search);
if (assetId === null) {
params.delete(PHOTO_QUERY_PARAM);
} else {
params.set(PHOTO_QUERY_PARAM, assetId);
}
const query = params.toString();
return query ? `?${query}` : '';
}
/**
* An absolute permalink to one photo, addressed by its stable asset id.
*
* Takes the location parts rather than reading `window`, so it is pure and the
* caller does not depend on the grid's hash-sync effect having run yet.
* Takes the location parts rather than reading `window`, so it is pure and
* the caller does not depend on the grid's query-sync effect having run yet.
*/
export function buildPhotoPermalink(
location: { origin: string; pathname: string; search: string },
index: number,
assetId: string,
): string {
return `${location.origin}${location.pathname}${location.search}${buildPhotoHash(index)}`;
return `${location.origin}${location.pathname}${buildPhotoQuery(location.search, assetId)}`;
}

View File

@@ -1,8 +1,17 @@
/**
* In-memory sliding-window rate limiter.
* In-memory rate limiter, weighted two-bucket sliding window.
* Tracks request counts per IP per minute bucket.
* Auto-evicts expired entries to prevent memory leaks.
*
* A single fixed-window count opens a boundary: a window closing sets
* `expiresAt` once and never advances it, so a burst timed around the
* boundary passes twice the configured limit (10 admin password attempts in
* one second instead of 5 per minute, say). This carries the previous
* window's count forward, weighted by how much of it is still "within" the
* last 60 seconds, so a burst can never exceed roughly `maxRpm` regardless of
* where in the window it lands — an approximation of a true sliding log,
* cheap enough to keep as an in-memory Map.
*
* ⚠️ NOTE: This is an in-memory store. In a multi-node or serverless
* environment (Vercel, AWS Lambda, Docker Swarm), each instance will
* have its own independent rate limit. For a global limit across
@@ -133,8 +142,12 @@ export function retryAfterSeconds(resetAt: number): number {
}
interface RateLimitEntry {
/** Epoch ms the *current* window started. */
windowStart: number;
/** Requests counted in the current window. */
count: number;
expiresAt: number;
/** Requests counted in the window immediately before this one. */
prevCount: number;
}
const store = new Map<string, RateLimitEntry>();
@@ -145,16 +158,33 @@ const MAX_STORE_ENTRIES = 10_000;
// Evict expired entries periodically (every 60s)
let lastCleanup = Date.now();
const CLEANUP_INTERVAL = 60_000;
const WINDOW_MS = 60_000;
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
// An entry is dead once both its windows have fully elapsed — the previous
// one no longer contributes any weight to the estimate either.
for (const [key, entry] of store) {
if (now > entry.expiresAt) store.delete(key);
if (now - entry.windowStart >= 2 * WINDOW_MS) store.delete(key);
}
}
/**
* The weighted estimate of requests within the trailing WINDOW_MS: every
* request in the current window, plus the previous window's count scaled by
* how much of it still overlaps the last WINDOW_MS. At the instant a window
* opens the overlap is total (weight 1); by the time it closes, none (weight
* 0) — linear in between, which is what makes a boundary-timed burst cost
* the same as one anywhere else in the window.
*/
function estimate(entry: RateLimitEntry, now: number): number {
const elapsed = now - entry.windowStart;
const weight = Math.max(0, (WINDOW_MS - elapsed) / WINDOW_MS);
return entry.prevCount * weight + entry.count;
}
/**
* Check if a request should be allowed.
* @param ip - Client IP address
@@ -168,13 +198,11 @@ export function checkRateLimit(
cleanup();
const now = Date.now();
const windowMs = 60_000;
const key = `rl:${ip}`;
const entry = store.get(key);
let entry = store.get(key);
// New window or expired
if (!entry || now > entry.expiresAt) {
if (!entry) {
if (store.size >= MAX_STORE_ENTRIES) {
// Store is full. Evict the oldest entry to prevent cache flooding
// DOS attacks, which could otherwise block all legitimate users.
@@ -183,16 +211,33 @@ export function checkRateLimit(
store.delete(oldestKey);
}
}
const resetAt = now + windowMs;
store.set(key, { count: 1, expiresAt: resetAt });
return { success: true, remaining: maxRpm - 1, resetAt };
entry = { windowStart: now, count: 0, prevCount: 0 };
store.set(key, entry);
} else {
const elapsed = now - entry.windowStart;
if (elapsed >= 2 * WINDOW_MS) {
// Both windows have fully elapsed: nothing carries forward.
entry.windowStart = now;
entry.count = 0;
entry.prevCount = 0;
} else if (elapsed >= WINDOW_MS) {
// Roll one window forward — anchored to the window boundary, not to
// `now`, so a burst of requests each advancing the window slightly
// cannot slowly drift `resetAt` later than a real minute.
entry.windowStart += WINDOW_MS;
entry.prevCount = entry.count;
entry.count = 0;
}
}
const resetAt = entry.windowStart + WINDOW_MS;
const current = estimate(entry, now);
if (current >= maxRpm) {
return { success: false, remaining: 0, resetAt };
}
// Within window
entry.count++;
if (entry.count > maxRpm) {
return { success: false, remaining: 0, resetAt: entry.expiresAt };
}
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
const remaining = Math.max(0, Math.floor(maxRpm - estimate(entry, now)));
return { success: true, remaining, resetAt };
}