release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// Mock env.ts so importing config.ts doesn't trigger Zod validation
vi.mock('@/lib/env', () => ({
@@ -17,47 +20,109 @@ vi.mock('@/lib/secret', () => ({
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
}));
// Replace the filesystem-backed YAML layer. The real loadYaml() already
// revalidates by mtime, so "the file changed on disk" is modelled by the
// mock returning different content.
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
// The real YAML layer, spied on: how often it is asked is how often the
// config is derived.
vi.mock('@/lib/config/parser', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/config/parser')>();
return { ...actual, loadYaml: vi.fn(actual.loadYaml) };
});
import { getConfig, invalidateConfigCache } from '@/lib/config';
import { loadYaml } from '@/lib/config/parser';
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
function mockYamlFiles(settings: Record<string, unknown>) {
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
if (filename === 'settings.yaml') return settings;
return null;
});
let dir: string;
let mtime = Date.now() / 1000;
/** Write a content file with a distinct mtime, the way a save from another worker lands. */
function write(name: string, body: string) {
const file = path.join(dir, 'content', name);
fs.writeFileSync(file, body);
mtime += 10;
fs.utimesSync(file, mtime, mtime);
}
describe('getConfig() in production', () => {
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'production');
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-config-cache-'));
fs.mkdirSync(path.join(dir, 'content'));
vi.spyOn(process, 'cwd').mockReturnValue(dir);
write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n`);
write('settings.yaml', 'title: Old Title\n');
invalidateConfigCache();
vi.mocked(loadYaml).mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
fs.rmSync(dir, { recursive: true, force: true });
});
it('picks up settings.yaml changes without invalidateConfigCache()', () => {
// An admin save may run in a different worker/process than page
// rendering, so this worker never sees invalidateConfigCache().
// getConfig() must delegate freshness to the mtime-checked YAML layer
// instead of short-circuiting on a stale in-memory copy.
mockYamlFiles({ title: 'Old Title' });
// getConfig() must notice the file changed instead of short-circuiting
// on a stale in-memory copy.
expect(getConfig().siteTitle).toBe('Old Title');
mockYamlFiles({ title: 'New Title' });
write('settings.yaml', 'title: New Title\n');
expect(getConfig().siteTitle).toBe('New Title');
});
it('picks up gallery.yaml changes without invalidateConfigCache()', () => {
expect(getConfig().albums).toEqual([ALBUM_ID]);
const other = '22222222-2222-2222-2222-222222222222';
write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n - "${other}"\n`);
expect(getConfig().albums).toEqual([ALBUM_ID, other]);
});
it('notices about.md appearing', () => {
expect(getConfig().aboutEnabled).toBe(false);
write('about.md', '---\nname: Someone\n---\n');
expect(getConfig().aboutEnabled).toBe(true);
});
it('notices install.json credentials appearing', () => {
// env supplies the URL; the file is still watched.
write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'file-key' }));
getConfig();
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2);
write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'other' }));
getConfig();
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4);
});
// Every image request and every page render call getConfig() many times
// over. Deriving the whole config each time cost ~150µs a call; the files
// did not change in between.
it('derives the config once while its files are unchanged', () => {
for (let i = 0; i < 100; i++) getConfig();
// gallery.yaml and settings.yaml, once — not 200 reads.
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2);
});
it('derives again after invalidateConfigCache()', () => {
getConfig();
invalidateConfigCache();
getConfig();
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4);
});
it('hands every caller its own copy', () => {
const first = getConfig();
first.albums.push('mutated');
first.siteTitle = 'mutated';
const second = getConfig();
expect(second.albums).toEqual([ALBUM_ID]);
expect(second.siteTitle).toBe('Old Title');
});
});