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:
@@ -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);
|
||||
});
|
||||
|
||||
120
lib/__tests__/atomicWrite.test.ts
Normal file
120
lib/__tests__/atomicWrite.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
111
lib/__tests__/backup-prune.test.ts
Normal file
111
lib/__tests__/backup-prune.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('@/lib/env', () => ({
|
||||
CACHE_TTL: 300,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
116
lib/__tests__/grid-columns-clamp.test.ts
Normal file
116
lib/__tests__/grid-columns-clamp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
107
lib/__tests__/immichCache.test.ts
Normal file
107
lib/__tests__/immichCache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
208
lib/__tests__/immichTransport.test.ts
Normal file
208
lib/__tests__/immichTransport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
92
lib/__tests__/journal-roundtrip.test.ts
Normal file
92
lib/__tests__/journal-roundtrip.test.ts
Normal 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 = '';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('does not double-encode an apostrophe in a photo-pair caption', () => {
|
||||
const source = "";
|
||||
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',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
].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");
|
||||
});
|
||||
});
|
||||
51
lib/__tests__/journalNav.test.ts
Normal file
51
lib/__tests__/journalNav.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
lib/__tests__/mapService.test.ts
Normal file
85
lib/__tests__/mapService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
lib/__tests__/navActive.test.ts
Normal file
56
lib/__tests__/navActive.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
124
lib/__tests__/settings-schema.test.ts
Normal file
124
lib/__tests__/settings-schema.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
62
lib/__tests__/theme-scalars.test.ts
Normal file
62
lib/__tests__/theme-scalars.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
32
lib/admin/withAdmin.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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
56
lib/atomicWrite.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
lib/auth.ts
16
lib/auth.ts
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
201
lib/config/settingsSchema.ts
Normal file
201
lib/config/settingsSchema.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
25
lib/env.ts
25
lib/env.ts
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
212
lib/immich.ts
212
lib/immich.ts
@@ -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
91
lib/immichCache.ts
Normal 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
130
lib/immichTransport.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
104
lib/journal.ts
104
lib/journal.ts
@@ -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(``);
|
||||
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
57
lib/journalNav.ts
Normal 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) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user