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

@@ -248,6 +248,106 @@ const ROUTES: {
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'GET /api/admin/pages',
path: 'pages',
load: () => import('../pages/route'),
method: 'GET',
args: () => [],
},
{
name: 'POST /api/admin/pages',
path: 'pages',
load: () => import('../pages/route'),
method: 'POST',
args: () => [new Request('http://localhost/api/admin/pages', { method: 'POST', body: '{}' })],
},
{
name: 'GET /api/admin/pages/[slug]',
path: 'pages/[slug]',
load: () => import('../pages/[slug]/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/pages/test-slug'),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'PUT /api/admin/pages/[slug]',
path: 'pages/[slug]',
load: () => import('../pages/[slug]/route'),
method: 'PUT',
args: () => [
new Request('http://localhost/api/admin/pages/test-slug', { method: 'PUT', body: '{}' }),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'DELETE /api/admin/pages/[slug]',
path: 'pages/[slug]',
load: () => import('../pages/[slug]/route'),
method: 'DELETE',
args: () => [
new Request('http://localhost/api/admin/pages/test-slug', { method: 'DELETE' }),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'GET /api/admin/proofing',
path: 'proofing',
load: () => import('../proofing/route'),
method: 'GET',
args: () => [new Request('http://localhost/api/admin/proofing')],
},
{
name: 'POST /api/admin/proofing',
path: 'proofing',
load: () => import('../proofing/route'),
method: 'POST',
args: () => [
new Request('http://localhost/api/admin/proofing', { method: 'POST', body: '{}' }),
],
},
{
name: 'GET /api/admin/proofing/[id]',
path: 'proofing/[id]',
load: () => import('../proofing/[id]/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/proofing/abc'),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'PATCH /api/admin/proofing/[id]',
path: 'proofing/[id]',
load: () => import('../proofing/[id]/route'),
method: 'PATCH',
args: () => [
new Request('http://localhost/api/admin/proofing/abc', { method: 'PATCH', body: '{}' }),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'DELETE /api/admin/proofing/[id]',
path: 'proofing/[id]',
load: () => import('../proofing/[id]/route'),
method: 'DELETE',
args: () => [
new Request('http://localhost/api/admin/proofing/abc', { method: 'DELETE' }),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'GET /api/admin/proofing/[id]/export',
path: 'proofing/[id]/export',
load: () => import('../proofing/[id]/export/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/proofing/abc/export'),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
];
describe('admin route guards', () => {

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => true,
isAdminAuthenticated: async () => true,
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(() => ({
needsCredentials: false,
immich: { apiUrl: 'http://immich.test/api', apiKey: 'key' },
})),
}));
import { GET } from '../assets/route';
type Item = { id: string; originalFileName: string; fileCreatedAt: string; isFavorite: boolean };
const item = (id: string): Item => ({
id,
originalFileName: `${id}.jpg`,
fileCreatedAt: '2024-05-17T10:00:00.000Z',
isFavorite: false,
});
let fetchMock: ReturnType<typeof vi.fn>;
const bodies = () =>
fetchMock.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string));
function respond(...pages: Array<{ items: Item[]; nextPage: string | null }>) {
for (const p of pages) {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ assets: p }), { status: 200 }));
}
}
async function get(query: string) {
const res = await GET(new NextRequest(`http://localhost/api/admin/assets?${query}`));
return { status: res.status, body: await res.json() };
}
describe('GET /api/admin/assets — search (#602)', () => {
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
it('browses without a query in one Immich call', async () => {
respond({ items: [item('a')], nextPage: '2' });
const { status, body } = await get('page=1&favorites=true');
expect(status).toBe(200);
expect(body).toEqual({ assets: [item('a')], nextPage: 2 });
expect(bodies()).toEqual([expect.objectContaining({ page: 1, isFavorite: true })]);
expect(bodies()[0]).not.toHaveProperty('originalFileName');
});
it('searches filename and description and merges without duplicates', async () => {
respond(
{ items: [item('a'), item('b')], nextPage: null },
{ items: [item('b'), item('c')], nextPage: '2' },
);
const { body } = await get('page=1&q=%20harbour%20');
expect(bodies()).toEqual([
expect.objectContaining({ originalFileName: 'harbour' }),
expect.objectContaining({ description: 'harbour' }),
]);
expect(body.assets.map((a: Item) => a.id)).toEqual(['a', 'b', 'c']);
expect(body.nextPage).toBe(2);
});
it.each([
['2024', '2024-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z'],
['2024-02', '2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
['2024-02-29', '2024-02-29T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
])('treats %s as a taken-date range', async (q, after, before) => {
respond({ items: [], nextPage: null });
await get(`q=${q}`);
expect(bodies()).toEqual([expect.objectContaining({ takenAfter: after, takenBefore: before })]);
});
it('falls back to text search for an impossible date', async () => {
respond({ items: [], nextPage: null }, { items: [], nextPage: null });
await get('q=2023-02-29');
expect(bodies()[0]).toMatchObject({ originalFileName: '2023-02-29' });
});
it.each(['0', '-1', 'abc', '1.5', '99999', '1e3'])('rejects page=%s', async (page) => {
const { status } = await get(`page=${page}`);
expect(status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects an overlong query before calling Immich', async () => {
const { status } = await get(`q=${'x'.repeat(101)}`);
expect(status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps an Immich error to 502', async () => {
fetchMock.mockResolvedValue(new Response('nope', { status: 500 }));
const { status } = await get('q=x');
expect(status).toBe(502);
});
});

View File

@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
/**
* Every content editor's route (#601): GET hands out a version, a PUT with a
* stale `If-Match` is answered 409 with the current version and writes
* nothing, a matching one saves and returns the new version, and a PUT with no
* `If-Match` at all — an admin tab from before the upgrade — still saves.
*
* Real services on a temp content/ directory; the routes resolve it from
* process.cwd() at import, so they are imported after cwd is pointed there.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
getConfigOrNull: vi.fn(() => null),
getConfig: vi.fn(() => ({
privacy: { enabled: true },
subpages: [],
albumPasswords: {},
lang: 'en',
})),
deriveGallery: vi.fn(() => ({ subpages: [] })),
isPageRef: (e: unknown) => !!e && typeof e === 'object' && 'page' in e,
}));
vi.mock('@/lib/privacy', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/privacy')>()),
processingFacts: vi.fn(() => []),
starterHeadings: vi.fn(() => ''),
}));
vi.mock('@/lib/admin/pageSlugs', () => ({
takenPageSlugs: vi.fn(async () => ({ subpages: [], albums: [], journal: [] })),
}));
vi.mock('@/lib/immich', () => ({ immich: { invalidateAll: vi.fn() } }));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
let root: string;
let content: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-conflict-'));
content = path.join(root, 'content');
await mkdir(path.join(content, 'journal'), { recursive: true });
await mkdir(path.join(content, 'pages'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const req = (method: string, body?: unknown, ifMatch?: string) =>
new Request('http://localhost/api/admin/x', {
method,
headers: {
'Content-Type': 'application/json',
...(ifMatch === undefined ? {} : { 'If-Match': ifMatch }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
type Handler = (request: Request, ctx?: never) => Promise<Response>;
interface Case {
name: string;
file: string;
initial: string;
load: () => Promise<{ GET: Handler; PUT: Handler }>;
ctx?: unknown;
body: (tag: string) => unknown;
}
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
const slugCtx = (slug: string) => ({ params: Promise.resolve({ slug }) });
const cases: Case[] = [
{
name: 'settings',
file: 'settings.yaml',
initial: 'title: Start\n',
load: () => import('../settings/route'),
body: (tag) => ({ settings: { title: tag } }),
},
{
name: 'gallery',
file: 'gallery.yaml',
initial: 'hero: []\nalbums: []\nsubpages: []\n',
load: () => import('../gallery/route'),
body: (tag) => ({ gallery: { hero: [], albums: [tag], subpages: [] } }),
},
{
name: 'about',
file: 'about.md',
initial: '---\nname: Start\n---\n\nHello\n',
load: () => import('../about/route'),
body: (tag) => ({ meta: { name: tag }, body: 'Hello' }),
},
{
name: 'privacy',
file: 'privacy.md',
initial: 'Start\n',
load: () => import('../privacy/route'),
body: (tag) => ({ body: tag }),
},
{
name: 'journal entry',
file: 'journal/trip.md',
initial: md('Start'),
load: () => import('../journal/[slug]/route') as never,
ctx: slugCtx('trip'),
body: (tag) => ({ rawMarkdown: md(tag) }),
},
{
name: 'content page',
file: 'pages/faq.md',
initial: md('Start'),
load: () => import('../pages/[slug]/route') as never,
ctx: slugCtx('faq'),
body: (tag) => ({ rawMarkdown: md(tag) }),
},
];
describe.each(cases)('$name route (#601)', ({ file, initial, load, ctx, body }) => {
const call = (h: Handler, r: Request) => h(r, ctx as never);
it('GET returns a version and ETag; a matching If-Match saves and returns the next one', async () => {
await writeFile(path.join(content, file), initial);
const { GET, PUT } = await load();
const got = await call(GET, req('GET'));
const { version } = await got.json();
expect(typeof version).toBe('string');
expect(got.headers.get('ETag')).toBe(`"${version}"`);
const saved = await call(PUT, req('PUT', body('Mine'), `"${version}"`));
expect(saved.status).toBe(200);
const data = await saved.json();
expect(data.version).toMatch(/^[0-9a-f]{32}$/);
expect(data.version).not.toBe(version);
});
it('a stale If-Match gets 409 with the current version and writes nothing', async () => {
await writeFile(path.join(content, file), initial);
const { GET, PUT } = await load();
const { version: stale } = await (await call(GET, req('GET'))).json();
// Another tab saves in between.
const other = await call(PUT, req('PUT', body('Theirs'), `"${stale}"`));
expect(other.status).toBe(200);
const { version: current } = await other.json();
const onDisk = await readFile(path.join(content, file), 'utf8');
const res = await call(PUT, req('PUT', body('Mine'), `"${stale}"`));
expect(res.status).toBe(409);
const data = await res.json();
expect(data).toMatchObject({ conflict: true, version: current });
expect(res.headers.get('ETag')).toBe(`"${current}"`);
await expect(readFile(path.join(content, file), 'utf8')).resolves.toBe(onDisk);
// "Overwrite anyway" resends with the version the 409 named.
const forced = await call(PUT, req('PUT', body('Mine'), `"${current}"`));
expect(forced.status).toBe(200);
});
it('a file that is not valid UTF-8 saves with the version GET handed out', async () => {
// A hand edit in Latin-1: "Über" as 0xDC, which UTF-8 cannot decode. The
// decoded text re-encodes to other bytes, so a version taken from it never
// matched the save's check and every save was a 409.
const latin1 = Buffer.concat([
Buffer.from(initial),
Buffer.from('\n# '),
Buffer.from([0xdc]),
Buffer.from('ber\n'),
]);
await writeFile(path.join(content, file), latin1);
const { GET, PUT } = await load();
const { version } = await (await call(GET, req('GET'))).json();
const res = await call(PUT, req('PUT', body('Mine'), `"${version}"`));
expect(res.status).toBe(200);
});
it('a PUT without If-Match still saves (older clients)', async () => {
await writeFile(path.join(content, file), initial);
const { PUT } = await load();
const res = await call(PUT, req('PUT', body('Mine')));
expect(res.status).toBe(200);
await expect(readFile(path.join(content, file), 'utf8')).resolves.not.toBe(initial);
});
});
describe('renames (#601)', () => {
it('a journal rename is checked against the entry it was loaded from', async () => {
await writeFile(path.join(content, 'journal/trip.md'), md('Start'));
const { GET, PUT } = await import('../journal/[slug]/route');
const ctx = slugCtx('trip');
const { version } = await (await GET(req('GET'), ctx)).json();
await writeFile(path.join(content, 'journal/trip.md'), md('Hand edit'));
const res = await PUT(req('PUT', { rawMarkdown: md('Mine'), newSlug: 'voyage' }, version), ctx);
expect(res.status).toBe(409);
await expect(readFile(path.join(content, 'journal/trip.md'), 'utf8')).resolves.toBe(
md('Hand edit'),
);
await expect(readFile(path.join(content, 'journal/voyage.md'))).rejects.toThrow();
});
it('a page rename reports the gallery.yaml version change it made', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), 'subpages:\n - page: faq\n');
const gallery = await import('../gallery/route');
const { version: galleryBefore } = await (await gallery.GET()).json();
const { GET, PUT } = await import('../pages/[slug]/route');
const ctx = slugCtx('faq');
const { version } = await (await GET(req('GET'), ctx)).json();
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ'), newSlug: 'help' }, version), ctx);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.menuRenamed).toBe(true);
expect(data.galleryVersion.from).toBe(galleryBefore);
const { version: galleryAfter } = await (await gallery.GET()).json();
expect(data.galleryVersion.to).toBe(galleryAfter);
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
/**
* A page rename or delete moves the file first and then follows up the menu
* reference in gallery.yaml (#722). When that follow-up fails — gallery.yaml
* edited by hand into invalid YAML, a write error — the page has already been
* renamed or deleted. Answering 500 then told the admin nothing was saved: the
* panel kept the old slug, and every retry hit a 404 for a file that was gone.
*
* Real services on a temp content/ directory, as in concurrent-edits.test.ts.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
isPageRef: (e: unknown) => !!e && typeof e === 'object' && 'page' in e,
}));
vi.mock('@/lib/admin/pageSlugs', () => ({
takenPageSlugs: vi.fn(async () => ({ subpages: [], albums: [], journal: [] })),
}));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
let root: string;
let content: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-page-refs-'));
content = path.join(root, 'content');
await mkdir(path.join(content, 'pages'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const req = (method: string, body?: unknown) =>
new Request('http://localhost/api/admin/pages/x', {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
const slugCtx = (slug: string) => ({ params: Promise.resolve({ slug }) });
const BROKEN_YAML = 'subpages:\n - page: faq\n bad: [unclosed\n';
describe('page rename when the menu cannot be updated', () => {
it('reports the rename as done, with a warning about the menu', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { PUT } = await import('../pages/[slug]/route');
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ'), newSlug: 'help' }), slugCtx('faq'));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.page.slug).toBe('help');
expect(data.menuRenamed).toBe(false);
expect(data.galleryVersion).toBeNull();
expect(data.warning).toMatch(/gallery\.yaml/);
// The rename itself happened, and gallery.yaml was left alone.
await expect(readFile(path.join(content, 'pages/help.md'), 'utf8')).resolves.toBe(md('FAQ'));
await expect(readFile(path.join(content, 'pages/faq.md'))).rejects.toThrow();
await expect(readFile(path.join(content, 'gallery.yaml'), 'utf8')).resolves.toBe(BROKEN_YAML);
});
it('a plain save does not touch the menu and carries no warning', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { PUT } = await import('../pages/[slug]/route');
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ 2') }), slugCtx('faq'));
expect(res.status).toBe(200);
expect((await res.json()).warning).toBeUndefined();
});
});
describe('page delete when the menu cannot be updated', () => {
it('reports the delete as done, with a warning about the menu', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { DELETE } = await import('../pages/[slug]/route');
const res = await DELETE(req('DELETE'), slugCtx('faq'));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.deletedSlug).toBe('faq');
expect(data.removedFromMenu).toBe(false);
expect(data.galleryVersion).toBeNull();
expect(data.warning).toMatch(/gallery\.yaml/);
await expect(readFile(path.join(content, 'pages/faq.md'))).rejects.toThrow();
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/admin/yaml-service', () => ({
readSettingsYaml: vi.fn(async () => ({
sitePassword: 'scrypt:stored:hash',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/stored' },
})),
readSettingsYamlVersioned: vi.fn(async () => ({
data: {
sitePassword: 'scrypt:stored:hash',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/stored' },
},
version: 'v1',
})),
writeSettingsYaml: vi.fn(async () => undefined),
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
getConfigOrNull: vi.fn(() => null),
}));
vi.mock('@/lib/immich', () => ({ immich: { invalidateAll: vi.fn() } }));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
const env = vi.hoisted(() => ({
SITE_PASSWORD: 'env-secret-password' as string | undefined,
CONTACT_NOTIFY_URL: 'https://ntfy.sh/env-secret-topic' as string | undefined,
SITE_TITLE: 'Env Title',
}));
vi.mock('@/lib/env', () => ({ env }));
import { writeSettingsYaml } from '@/lib/admin/yaml-service';
import { GET, PUT } from '../settings/route';
/**
* The panel renders these fields locked (#605). The payload carries only the
* variable names: the values are a password and an ntfy topic.
*/
describe('/api/admin/settings env locks (#605)', () => {
beforeEach(() => {
vi.clearAllMocks();
env.SITE_PASSWORD = 'env-secret-password';
env.CONTACT_NOTIFY_URL = 'https://ntfy.sh/env-secret-topic';
});
it('GET names the locking variables and never their values', async () => {
const res = await GET();
const text = await res.text();
expect(JSON.parse(text).envLocks).toEqual({
sitePassword: 'SITE_PASSWORD',
'contact.notifyUrl': 'CONTACT_NOTIFY_URL',
});
expect(text).not.toContain('env-secret-password');
expect(text).not.toContain('env-secret-topic');
});
it('GET reports no locks when the variables are unset', async () => {
env.SITE_PASSWORD = undefined;
env.CONTACT_NOTIFY_URL = undefined;
const body = await (await GET()).json();
expect(body.envLocks).toEqual({});
});
it('PUT keeps the stored value of a locked field', async () => {
const res = await PUT(
new Request('http://localhost/api/admin/settings', {
method: 'PUT',
body: JSON.stringify({
settings: {
title: 'Folio',
sitePassword: 'typed-in-panel',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/typed' },
},
}),
}),
);
expect(res.status).toBe(200);
expect(vi.mocked(writeSettingsYaml).mock.calls[0][0]).toMatchObject({
title: 'Folio',
sitePassword: 'scrypt:stored:hash',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/stored' },
});
});
});

View File

@@ -95,3 +95,41 @@ describe('PUT /api/admin/settings — site password', () => {
expect(written.sitePassword).toBe('');
});
});
/**
* Values the site would ignore used to be written with a success message: the
* accent fell back to the preset, the columns were clamped, the sitemap went
* out without an address (QA A-14).
*/
describe('PUT /api/admin/settings — values the site would ignore', () => {
beforeEach(() => vi.clearAllMocks());
it('refuses them, names each field, and writes nothing', async () => {
const res = await put({
url: 'folio.example',
theme: { preset: 'minimal', accent: 'rot' },
grid: { columns: 9, gap: 12 },
});
const body = await res.json();
expect(res.status).toBe(400);
expect(writeSettingsYaml).not.toHaveBeenCalled();
expect(body.fields.map((f: { field: string }) => f.field).sort()).toEqual([
'grid.columns',
'theme.accent',
'url',
]);
expect(body.fields.every((f: { message: string }) => f.message.length > 0)).toBe(true);
});
it('writes the same fields once they hold values the site uses', async () => {
const res = await put({
url: 'https://folio.example',
theme: { preset: 'minimal', accent: '#e60012' },
grid: { columns: 6, gap: 0 },
});
expect(res.status).toBe(200);
expect(writeSettingsYaml).toHaveBeenCalledOnce();
});
});

View File

@@ -5,6 +5,16 @@ import { promises as fs } from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { atomicWrite } from '@/lib/atomicWrite';
import {
VersionConflictError,
assertVersion,
baseVersionFrom,
conflictResponse,
etag,
readVersioned,
serializeContentWrite,
versionOf,
} from '@/lib/admin/contentVersion';
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
const FILENAME = 'about.md';
@@ -26,9 +36,13 @@ export const GET = withAdmin(async () => {
const filePath = path.join(CONTENT_DIR, FILENAME);
let meta: AboutMeta = {};
let body = '';
let version = versionOf(null);
try {
const raw = await fs.readFile(filePath, 'utf-8');
// The version comes from the bytes read, not the decoded text (#601).
const read = await readVersioned(filePath);
version = read.version;
const raw = read.text;
const match = raw.match(/^(?:---\r?\n)([\s\S]*?)(?:\r?\n---\r?\n)([\s\S]*)$/);
if (match) {
try {
@@ -42,7 +56,8 @@ export const GET = withAdmin(async () => {
// File doesn't exist yet — return empty defaults
}
return NextResponse.json({ meta, body });
// `version` goes back in If-Match on save (#601).
return NextResponse.json({ meta, body, version }, { headers: { ETag: etag(version) } });
});
export const PUT = withAdmin(async (request: Request) => {
@@ -64,38 +79,53 @@ export const PUT = withAdmin(async (request: Request) => {
const content = `---\n${frontmatter}\n---\n\n${data.body ?? ''}\n`;
const filePath = path.join(CONTENT_DIR, FILENAME);
await fs.mkdir(CONTENT_DIR, { recursive: true });
// "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: 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;
const baseVersion = baseVersionFrom(request);
try {
await fs.access(filePath);
await serializeContentWrite(async () => {
await assertVersion(filePath, baseVersion);
await fs.mkdir(CONTENT_DIR, { recursive: true });
// "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: 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?: string }).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await fs.copyFile(filePath, path.join(backupDir, `${FILENAME}.${timestamp}.bak`));
// Prune old backups
const entries = await fs.readdir(backupDir);
const aboutBackups = entries
.filter((e) => e.startsWith(FILENAME) && e.endsWith('.bak'))
.sort();
while (aboutBackups.length > MAX_BACKUPS) {
await fs.unlink(path.join(backupDir, aboutBackups.shift()!));
}
}
await atomicWrite(filePath, content);
});
} catch (err) {
if ((err as NodeJS.ErrnoException & { code?: string }).code !== 'ENOENT') throw err;
fileExists = false;
if (err instanceof VersionConflictError) return conflictResponse(err.currentVersion);
throw err;
}
if (fileExists) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await fs.copyFile(filePath, path.join(backupDir, `${FILENAME}.${timestamp}.bak`));
// Prune old backups
const entries = await fs.readdir(backupDir);
const aboutBackups = entries.filter((e) => e.startsWith(FILENAME) && e.endsWith('.bak')).sort();
while (aboutBackups.length > MAX_BACKUPS) {
await fs.unlink(path.join(backupDir, aboutBackups.shift()!));
}
}
await atomicWrite(filePath, content);
revalidatePath('/about', 'layout');
return NextResponse.json({ success: true, message: 'About page saved.' });
return NextResponse.json({
success: true,
message: 'About page saved.',
version: versionOf(content),
});
});

View File

@@ -81,7 +81,7 @@ describe('GET /api/admin/albums/[albumId]/assets', () => {
it('reads assets through the Immich client rather than the album endpoint', async () => {
await call(ALBUM);
expect(getAlbumAssetsRaw).toHaveBeenCalledWith(ALBUM);
expect(getAlbumAssetsRaw).toHaveBeenCalledWith(ALBUM, true);
});
it('reports an upstream failure as a 500 rather than an empty album', async () => {

View File

@@ -37,8 +37,9 @@ export const GET = withAdmin(
// longer embeds assets in that response, so reading `album.assets` returned
// nothing (and threw outright when the key was absent). getAlbumAssetsRaw()
// pages through the metadata search and applies the album's own order, so
// this list matches what the site renders under `sort: immich`.
const all = await immich.getAlbumAssetsRaw(albumId);
// this list matches what the site renders under `sort: immich`. Fresh,
// not cached: the editors must show the album as Immich has it now.
const all = await immich.getAlbumAssetsRaw(albumId, true);
const assets = all
.filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO'))

View File

@@ -1,6 +1,9 @@
/**
* Admin API: Browse Immich assets for the hero picker.
* Supports pagination and fetching favorites first.
* Admin API: browse and search Immich assets for the asset picker.
* Supports pagination, favourites and a free-text search (#602).
*
* Read-only against Immich: every call is `POST /search/metadata`, a query
* despite the verb.
*/
import { NextRequest, NextResponse } from 'next/server';
@@ -21,7 +24,41 @@ interface ImmichSearchResult {
};
}
/** GET: Search/browse assets from Immich for hero image selection. */
class ImmichStatusError extends Error {
constructor(readonly status: number) {
super(`Immich API returned ${status}`);
}
}
const PAGE_SIZE = 50;
/** Far beyond any real scroll depth; keeps a crafted `page` away from Immich. */
const MAX_PAGE = 1000;
/** Longer than any filename or description fragment anybody types. */
const MAX_QUERY_LENGTH = 100;
/**
* A query that is a date — `2024`, `2024-05` or `2024-05-17` — searches by the
* year, month or day the photo was taken instead of by text. Null for anything
* else, including impossible dates such as `2024-13` or `2024-02-31`.
*/
function dateRange(q: string): { takenAfter: string; takenBefore: string } | null {
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(q);
if (!m) return null;
const year = Number(m[1]);
const month = m[2] ? Number(m[2]) : null;
const day = m[3] ? Number(m[3]) : null;
if (month !== null && (month < 1 || month > 12)) return null;
const start = new Date(Date.UTC(year, (month ?? 1) - 1, day ?? 1));
// Date.UTC rolls 2024-02-31 over into March; refuse rather than guess.
if (day !== null && start.getUTCDate() !== day) return null;
const end = new Date(start);
if (day !== null) end.setUTCDate(end.getUTCDate() + 1);
else if (month !== null) end.setUTCMonth(end.getUTCMonth() + 1);
else end.setUTCFullYear(end.getUTCFullYear() + 1);
return { takenAfter: start.toISOString(), takenBefore: end.toISOString() };
}
/** GET: browse (`?page=&favorites=`) or search (`&q=`) the library. */
export const GET = withAdmin(async (request: NextRequest) => {
const config = getConfig();
// Credentials, not `needsSetup`: this route is how the operator picks the
@@ -32,51 +69,78 @@ export const GET = withAdmin(async (request: NextRequest) => {
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const rawPage = searchParams.get('page') ?? '1';
const page = /^\d{1,4}$/.test(rawPage) ? Number(rawPage) : NaN;
if (!Number.isInteger(page) || page < 1 || page > MAX_PAGE) {
return NextResponse.json({ error: 'Invalid page' }, { status: 400 });
}
const favoritesOnly = searchParams.get('favorites') === 'true';
const pageSize = 50;
const q = (searchParams.get('q') ?? '').trim();
if (q.length > MAX_QUERY_LENGTH) {
return NextResponse.json({ error: 'Search query too long' }, { status: 400 });
}
const base: Record<string, unknown> = {
type: 'IMAGE',
size: PAGE_SIZE,
page,
order: 'desc',
isNotInAlbum: false,
};
if (favoritesOnly) base.isFavorite = true;
// Immich's metadata search ANDs its fields, so "filename or description"
// takes two searches whose pages are merged.
const range = q ? dateRange(q) : null;
const bodies: Record<string, unknown>[] = range
? [{ ...base, ...range }]
: q
? [
{ ...base, originalFileName: q },
{ ...base, description: q },
]
: [base];
try {
// Use Immich search/metadata endpoint to query assets
const body: Record<string, unknown> = {
type: 'IMAGE',
size: pageSize,
page,
order: 'desc',
isNotInAlbum: false,
};
const results = await Promise.all(
bodies.map(async (body) => {
const res = await fetch(`${config.immich.apiUrl}/search/metadata`, {
method: 'POST',
headers: {
'x-api-key': config.immich.apiKey,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) throw new ImmichStatusError(res.status);
return (await res.json()) as ImmichSearchResult;
}),
);
if (favoritesOnly) {
body.isFavorite = true;
}
const res = await fetch(`${config.immich.apiUrl}/search/metadata`, {
method: 'POST',
headers: {
'x-api-key': config.immich.apiKey,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 });
}
const data = (await res.json()) as ImmichSearchResult;
const assets = data.assets.items.map((asset) => ({
id: asset.id,
originalFileName: asset.originalFileName,
fileCreatedAt: asset.fileCreatedAt,
isFavorite: asset.isFavorite,
}));
const seen = new Set<string>();
const assets = results
.flatMap((r) => r.assets.items)
.filter((a) => {
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
})
.map((asset) => ({
id: asset.id,
originalFileName: asset.originalFileName,
fileCreatedAt: asset.fileCreatedAt,
isFavorite: asset.isFavorite,
}));
return NextResponse.json({
assets,
nextPage: data.assets.nextPage ? page + 1 : null,
nextPage: results.some((r) => r.assets.nextPage) ? page + 1 : null,
});
} catch (err) {
if (err instanceof ImmichStatusError) {
return NextResponse.json({ error: `Immich API returned ${err.status}` }, { status: 502 });
}
console.error('[Admin] Failed to fetch assets from Immich:', err);
return NextResponse.json({ error: 'Failed to connect to Immich' }, { status: 502 });
}

View File

@@ -3,19 +3,19 @@ import { withAdmin } from '@/lib/admin/withAdmin';
import { revalidatePath } from 'next/cache';
import { listBackups, restoreBackup } from '@/lib/admin/yaml-service';
import { listJournalBackups, restoreJournalBackup } from '@/lib/admin/journal-service';
import { listPageBackups, restorePageBackup } from '@/lib/admin/pages-service';
import { invalidateConfigCache } from '@/lib/config';
import { immich } from '@/lib/immich';
export type BackupTarget = 'gallery' | 'settings' | 'about' | 'privacy' | 'journal';
export type BackupTarget = 'gallery' | 'settings' | 'about' | 'privacy' | 'journal' | 'pages';
export interface BackupItem {
filename: string;
target: BackupTarget;
timestamp: string | null;
isPreRestore: boolean;
/** Journal backups only: the entry the backup belongs to. */
/** Journal and page backups only: the entry or page the backup belongs to. */
slug?: string;
/** Journal backups only: the snapshot taken when the entry was deleted. */
/** Journal and page backups only: the snapshot taken when it was deleted. */
isDeleted?: boolean;
}
@@ -60,6 +60,13 @@ export const GET = withAdmin(async () => {
isDeleted: b.kind === 'deleted',
}))
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''));
const pages = (await listPageBackups())
.map((b) => ({
...parseBackupInfo(b.filename, 'pages'),
slug: b.slug,
isDeleted: b.kind === 'deleted',
}))
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''));
return NextResponse.json({
backups: {
@@ -68,6 +75,7 @@ export const GET = withAdmin(async () => {
about,
privacy,
journal,
pages,
},
});
} catch (err) {
@@ -103,13 +111,18 @@ export const POST = withAdmin(async (req: Request) => {
// cannot be told apart from an about.md backup by name alone.
if (target === 'journal') {
await restoreJournalBackup(backupFilename);
} else if (target === 'pages') {
await restorePageBackup(backupFilename);
} else {
await restoreBackup(backupFilename);
}
// Invalidate caches & revalidate pages
invalidateConfigCache();
immich.invalidateAll();
// No immich.invalidateAll(): the Immich cache holds Immich's data only, and
// the gallery.yaml side (allowlist, titles, order) is applied per request
// from the new config. Clearing it made the next visitor refetch every
// album although nothing in Immich had changed.
revalidatePath('/', 'layout');
return NextResponse.json({

View File

@@ -6,7 +6,11 @@ import { getConfig, slugify } from '@/lib/config';
import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service';
import { readSettingsYaml } from '@/lib/admin/yaml-service';
import { validateSettingValues } from '@/lib/config/settingValues';
import { readPrivacy } from '@/lib/privacy';
import { listPageSlugsSync } from '@/lib/admin/pages-service';
import { takenPageSlugs } from '@/lib/admin/pageSlugs';
import { describeCollision, menuPageSlugs, pageSlugCollision } from '@/lib/pages';
import {
checkAlbumIds,
checkAlbumSlugCollisions,
@@ -15,8 +19,10 @@ import {
checkCdn,
checkImmichCalls,
checkContact,
checkContentPages,
checkLegal,
checkPrivacy,
checkSettingValues,
checkPasswords,
checkProxyHops,
checkWritable,
@@ -153,6 +159,9 @@ export const GET = withAdmin(async (request: NextRequest) => {
const settings = await readSettingsYaml();
rawLegal = settings?.legal ?? rawLegal;
rawContact = settings?.contact ?? rawContact;
// Values the resolvers replaced on the way into `config` — only the raw
// file still shows them.
findings.push(checkSettingValues(validateSettingValues(settings)));
} catch {
// Fall back to the resolved blocks; only a dropped URL goes unseen.
}
@@ -167,10 +176,30 @@ export const GET = withAdmin(async (request: NextRequest) => {
});
if (privacy) findings.push(privacy);
// ── Content pages: missing menu targets and slug collisions (#722) ────
try {
const pageSlugs = listPageSlugsSync();
const taken = pageSlugs.length ? await takenPageSlugs() : null;
const collisions = taken
? pageSlugs.flatMap((slug) => {
const collision = pageSlugCollision(slug, taken);
return collision ? [{ slug, reason: describeCollision(slug, collision) }] : [];
})
: [];
const pages = checkContentPages({
menuRefs: menuPageSlugs(config.nav),
pages: pageSlugs,
collisions,
});
if (pages) findings.push(pages);
} catch {
// Pages are optional; a failure to list them is not a finding of its own.
}
// ── Writability of the content volume ────────────────────────────────
const contentDir = path.join(process.cwd(), 'content');
const unwritable: string[] = [];
for (const dir of ['', '.backups', 'journal']) {
for (const dir of ['', '.backups', 'journal', 'pages']) {
const target = path.join(contentDir, dir);
try {
await fs.access(target, (await import('node:fs')).constants.W_OK);

View File

@@ -1,18 +1,32 @@
import { NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin';
import { revalidatePath } from 'next/cache';
import { readGalleryYaml, writeGalleryYaml } from '@/lib/admin/yaml-service';
import {
readGalleryYaml,
readGalleryYamlVersioned,
writeGalleryYaml,
} from '@/lib/admin/yaml-service';
import {
VersionConflictError,
baseVersionFrom,
conflictResponse,
etag,
} from '@/lib/admin/contentVersion';
import { invalidateConfigCache, deriveGallery } from '@/lib/config';
import { immich } from '@/lib/immich';
import type { GalleryYaml } from '@/lib/config/schema';
import { hashPasswordKeys } from '@/lib/admin/passwordHashing';
import { listPageSlugsSync } from '@/lib/admin/pages-service';
const PASSWORD_KEY = new Set(['password']);
/** GET: Read current gallery.yaml config. */
export const GET = withAdmin(async () => {
const gallery = await readGalleryYaml();
return NextResponse.json({ gallery: gallery || { hero: [], albums: [], subpages: [] } });
const { data: gallery, version } = await readGalleryYamlVersioned();
// `version` goes back in If-Match on save (#601).
return NextResponse.json(
{ gallery: gallery || { hero: [], albums: [], subpages: [] }, version },
{ headers: { ETag: etag(version) } },
);
});
/** PUT: Write gallery.yaml config. */
@@ -39,7 +53,17 @@ export const PUT = withAdmin(async (request: Request) => {
// validating before the write rather than rolling back after — a rollback
// leaves a window in which other requests read the broken config.
try {
deriveGallery(gallery);
const derived = deriveGallery(gallery);
// A subpage renamed onto a content page's slug would shadow the page
// (#722). The page side of the same rule is enforced where pages save.
const pageSlugs = new Set(listPageSlugsSync());
const clash = derived.subpages.find((sp) => pageSlugs.has(sp.slug));
if (clash) {
throw new Error(
`Subpage "${clash.name}" would take /${clash.slug}, which is already a content page. ` +
`Rename one of them.`,
);
}
} catch (err) {
const reason = err instanceof Error ? err.message : 'Invalid gallery structure';
console.warn('[Admin] Rejected an unloadable gallery.yaml:', reason);
@@ -53,9 +77,12 @@ export const PUT = withAdmin(async (request: Request) => {
PASSWORD_KEY,
await readGalleryYaml().catch(() => null),
);
await writeGalleryYaml(toWrite);
const version = await writeGalleryYaml(toWrite, baseVersionFrom(request));
invalidateConfigCache();
immich.invalidateAll();
// No immich.invalidateAll(): the Immich cache holds Immich's data only, and
// the gallery.yaml side (allowlist, titles, order) is applied per request
// from the new config. Clearing it made the next visitor refetch every
// album although nothing in Immich had changed.
// Revalidate all pages so the homepage picks up new hero images immediately
revalidatePath('/', 'layout');
revalidatePath('/[...path]', 'page');
@@ -64,8 +91,10 @@ export const PUT = withAdmin(async (request: Request) => {
message: 'Saved successfully. Backup of previous version created.',
// What was written, passwords hashed, so the editor can take it over.
gallery: toWrite,
version,
});
} catch (err) {
if (err instanceof VersionConflictError) return conflictResponse(err.currentVersion);
console.error('[Admin] Failed to write gallery.yaml:', err);
return NextResponse.json({ error: 'Failed to save gallery config' }, { status: 500 });
}

View File

@@ -4,9 +4,15 @@ import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal';
import { hashFrontmatterPassword } from '@/lib/admin/passwordHashing';
import {
readJournalEntry,
writeJournalEntry,
saveJournalEntry,
deleteJournalEntry,
} from '@/lib/admin/journal-service';
import {
VersionConflictError,
baseVersionFrom,
conflictResponse,
etag,
} from '@/lib/admin/contentVersion';
interface RouteContext {
params: Promise<{ slug: string }>;
@@ -23,7 +29,9 @@ export const GET = withAdmin(async (request: Request, context: RouteContext) =>
if (!entry) {
return NextResponse.json({ error: 'Journal entry not found' }, { status: 404 });
}
return NextResponse.json({ entry });
// `version` goes back in If-Match on save (#601).
const version = entry.version;
return NextResponse.json({ entry, version }, { headers: { ETag: etag(version) } });
} catch (err) {
console.error(`[Admin API] Failed to get journal entry "${slug}":`, err);
return NextResponse.json({ error: 'Failed to read journal entry' }, { status: 500 });
@@ -67,16 +75,16 @@ export const PUT = withAdmin(async (request: Request, context: RouteContext) =>
const current = await readJournalEntry(slug);
const markdown = await hashFrontmatterPassword(rawMarkdown, current?.rawMarkdown ?? null);
if (targetSlug !== slug) {
await writeJournalEntry(targetSlug, markdown);
await deleteJournalEntry(slug);
} else {
await writeJournalEntry(slug, markdown);
}
// A rename is checked against the file the editor loaded, the old slug.
const version = await saveJournalEntry(targetSlug, markdown, {
baseVersion: baseVersionFrom(request),
fromSlug: slug,
});
const updated = await readJournalEntry(targetSlug);
return NextResponse.json({ success: true, entry: updated });
return NextResponse.json({ success: true, entry: updated, version });
} catch (err) {
if (err instanceof VersionConflictError) return conflictResponse(err.currentVersion);
console.error(`[Admin API] Failed to update journal entry "${slug}":`, err);
return NextResponse.json({ error: 'Failed to update journal entry' }, { status: 500 });
}

View File

@@ -0,0 +1,161 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { isValidSlug, parseJournalMarkdown, sanitizeSlug } from '@/lib/journal';
import { describeCollision, pageSlugCollision } from '@/lib/pages';
import { hashFrontmatterPassword } from '@/lib/admin/passwordHashing';
import { deletePage, readPage, savePage } from '@/lib/admin/pages-service';
import {
VersionConflictError,
baseVersionFrom,
conflictResponse,
etag,
} from '@/lib/admin/contentVersion';
import { takenPageSlugs } from '@/lib/admin/pageSlugs';
import { updatePageRefs, type GalleryVersionChange } from '@/lib/admin/pageRefs';
interface RouteContext {
params: Promise<{ slug: string }>;
}
/**
* Follow a page rename or delete in gallery.yaml's menu. Runs after the page
* file already moved, so a failure here (gallery.yaml edited into invalid
* YAML, a write error) must not turn the answer into a 500: the admin would be
* told nothing was saved, keep the old slug, and hit a 404 on every retry.
* The page change stands; the menu is left as it was and the admin is told.
*/
async function followInMenu(
from: string,
to: string | null,
): Promise<{ galleryVersion: GalleryVersionChange | null; warning?: string }> {
try {
return { galleryVersion: await updatePageRefs(from, to) };
} catch (err) {
console.error(`[Admin API] Page "${from}" changed, but gallery.yaml was not updated:`, err);
return {
galleryVersion: null,
warning:
to === null
? `Page deleted, but its menu entry could not be removed from gallery.yaml. Remove "page: ${from}" there by hand.`
: `Page renamed, but the menu in gallery.yaml could not be updated. Change "page: ${from}" to "page: ${to}" there by hand.`,
};
}
}
export const GET = withAdmin(async (_request: Request, context: RouteContext) => {
const { slug } = await context.params;
if (!isValidSlug(slug)) {
return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
}
try {
const page = await readPage(slug);
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
// `version` goes back in If-Match on save (#601).
const version = page.version;
return NextResponse.json({ page, version }, { headers: { ETag: etag(version) } });
} catch (err) {
console.error(`[Admin API] Failed to read page "${slug}":`, err);
return NextResponse.json({ error: 'Failed to read page' }, { status: 500 });
}
});
/**
* Save a page. `newSlug` renames it: the slug is checked like a new one, the
* menu reference in gallery.yaml follows, and old links break — the admin
* says so before sending it. There are no automatic redirects (#722).
*/
export const PUT = withAdmin(async (request: Request, context: RouteContext) => {
const { slug } = await context.params;
if (!isValidSlug(slug)) {
return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
}
const body = await request.json().catch(() => null);
const rawMarkdown = body?.rawMarkdown;
if (typeof rawMarkdown !== 'string') {
return NextResponse.json({ error: 'Missing rawMarkdown content' }, { status: 400 });
}
try {
const current = await readPage(slug);
if (!current) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
// Refuse what the parser cannot read before anything is written.
parseJournalMarkdown(rawMarkdown);
const targetSlug =
typeof body.newSlug === 'string' && body.newSlug ? sanitizeSlug(body.newSlug) : slug;
if (targetSlug !== slug) {
const collision = pageSlugCollision(targetSlug, await takenPageSlugs());
if (collision) {
return NextResponse.json(
{ error: describeCollision(targetSlug, collision) },
{ status: 409 },
);
}
if (await readPage(targetSlug)) {
return NextResponse.json(
{ error: `A page "${targetSlug}" already exists.` },
{ status: 409 },
);
}
}
// Stored hashed (#690); the current file supplies the hash to keep.
const markdown = await hashFrontmatterPassword(rawMarkdown, current.rawMarkdown);
// A rename is checked against the file the editor loaded, the old slug.
const version = await savePage(targetSlug, markdown, {
baseVersion: baseVersionFrom(request),
fromSlug: slug,
});
const { galleryVersion, warning } =
targetSlug !== slug
? await followInMenu(slug, targetSlug)
: { galleryVersion: null, warning: undefined };
revalidatePath('/', 'layout');
return NextResponse.json({
success: true,
page: await readPage(targetSlug),
menuRenamed: galleryVersion !== null,
// So the open page builder can follow the menu rewrite (#601).
galleryVersion,
version,
...(warning ? { warning } : {}),
});
} catch (err) {
if (err instanceof VersionConflictError) return conflictResponse(err.currentVersion);
console.error(`[Admin API] Failed to save page "${slug}":`, err);
return NextResponse.json({ error: 'Failed to save page' }, { status: 500 });
}
});
/**
* Delete a page. A `.deleted.bak` copy is kept, and a menu reference is
* removed from gallery.yaml (backed up as well), so the menu does not keep
* pointing at nothing.
*/
export const DELETE = withAdmin(async (_request: Request, context: RouteContext) => {
const { slug } = await context.params;
if (!isValidSlug(slug)) {
return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
}
try {
const deleted = await deletePage(slug);
if (!deleted) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
const { galleryVersion, warning } = await followInMenu(slug, null);
revalidatePath('/', 'layout');
return NextResponse.json({
success: true,
deletedSlug: slug,
removedFromMenu: galleryVersion !== null,
galleryVersion,
...(warning ? { warning } : {}),
});
} catch (err) {
console.error(`[Admin API] Failed to delete page "${slug}":`, err);
return NextResponse.json({ error: 'Failed to delete page' }, { status: 500 });
}
});

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { sanitizeSlug, serializeJournalMarkdown } from '@/lib/journal';
import { describeCollision, pageSlugCollision } from '@/lib/pages';
import { listPages, readPage, writePage } from '@/lib/admin/pages-service';
import { takenPageSlugs } from '@/lib/admin/pageSlugs';
/**
* Content pages (#722). GET lists every page, with the slugs a page may not
* take so the admin can warn while the operator types; POST creates one.
*
* Whether a page sits in the menu is gallery.yaml's business, saved by the
* page builder — creating a page never touches the menu.
*/
export const GET = withAdmin(async () => {
try {
const [pages, taken] = await Promise.all([listPages(), takenPageSlugs()]);
return NextResponse.json({ pages, taken });
} catch (err) {
console.error('[Admin API] Failed to list pages:', err);
return NextResponse.json({ error: 'Failed to list pages' }, { status: 500 });
}
});
export const POST = withAdmin(async (request: Request) => {
const body = await request.json().catch(() => null);
const title = typeof body?.title === 'string' ? body.title.trim().replace(/\s+/g, ' ') : '';
if (!title) {
return NextResponse.json({ error: 'A page needs a title' }, { status: 400 });
}
const slug = sanitizeSlug(typeof body?.slug === 'string' && body.slug ? body.slug : title);
try {
const collision = pageSlugCollision(slug, await takenPageSlugs());
if (collision) {
return NextResponse.json({ error: describeCollision(slug, collision) }, { status: 409 });
}
if (await readPage(slug)) {
return NextResponse.json({ error: `A page "${slug}" already exists.` }, { status: 409 });
}
// Created as a draft: an empty page should not go live before it has
// content, whatever the menu says.
const markdown = serializeJournalMarkdown({
frontmatter: { title, draft: true },
blocks: [],
referencedAssetIds: [],
});
await writePage(slug, markdown);
revalidatePath('/', 'layout');
return NextResponse.json({ success: true, page: await readPage(slug) }, { status: 201 });
} catch (err) {
console.error('[Admin API] Failed to create page:', err);
return NextResponse.json({ error: 'Failed to create page' }, { status: 500 });
}
});

View File

@@ -4,6 +4,16 @@ import path from 'path';
import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { atomicWrite } from '@/lib/atomicWrite';
import {
VersionConflictError,
assertVersion,
baseVersionFrom,
conflictResponse,
etag,
fileVersion,
serializeContentWrite,
versionOf,
} from '@/lib/admin/contentVersion';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { PRIVACY_FILENAME, processingFacts, readPrivacy, starterHeadings } from '@/lib/privacy';
@@ -26,14 +36,19 @@ export const GET = withAdmin(async () => {
config.subpages.some((sp) => !!sp.password) ||
Object.values(config.albumPasswords).some(Boolean),
});
// Read before the text: a save landing between the two then makes the
// version stale, which costs a spurious conflict, never a missed one.
const version = await fileVersion(path.join(CONTENT_DIR, PRIVACY_FILENAME));
return NextResponse.json(
{
body: readPrivacy(CONTENT_DIR),
// Sent back in If-Match on save (#601).
version,
enabled: config.privacy.enabled,
facts,
starter: starterHeadings(config.lang, facts),
},
{ headers: { 'Cache-Control': 'no-store' } },
{ headers: { 'Cache-Control': 'no-store', ETag: etag(version) } },
);
});
@@ -45,34 +60,48 @@ export const PUT = withAdmin(async (request: Request) => {
}
const filePath = path.join(CONTENT_DIR, PRIVACY_FILENAME);
await fs.mkdir(CONTENT_DIR, { recursive: true });
// Same rule as the about route (#630): only a missing file means there is
// nothing to back up. Any other failure aborts before the live file changes.
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 });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await fs.copyFile(filePath, path.join(backupDir, `${PRIVACY_FILENAME}.${timestamp}.bak`));
const backups = (await fs.readdir(backupDir))
.filter((e) => e.startsWith(PRIVACY_FILENAME) && e.endsWith('.bak'))
.sort();
while (backups.length > MAX_BACKUPS) {
await fs.unlink(path.join(backupDir, backups.shift()!));
}
}
const body = data.body.trim();
await atomicWrite(filePath, body ? `${body}\n` : '');
const content = body ? `${body}\n` : '';
const baseVersion = baseVersionFrom(request);
try {
await serializeContentWrite(async () => {
await assertVersion(filePath, baseVersion);
await fs.mkdir(CONTENT_DIR, { recursive: true });
// Same rule as the about route (#630): only a missing file means there is
// nothing to back up. Any other failure aborts before the live file changes.
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 });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await fs.copyFile(filePath, path.join(backupDir, `${PRIVACY_FILENAME}.${timestamp}.bak`));
const backups = (await fs.readdir(backupDir))
.filter((e) => e.startsWith(PRIVACY_FILENAME) && e.endsWith('.bak'))
.sort();
while (backups.length > MAX_BACKUPS) {
await fs.unlink(path.join(backupDir, backups.shift()!));
}
}
await atomicWrite(filePath, content);
});
} catch (err) {
if (err instanceof VersionConflictError) return conflictResponse(err.currentVersion);
throw err;
}
revalidatePath('/', 'layout');
return NextResponse.json({ success: true, message: 'Privacy policy saved.' });
return NextResponse.json({
success: true,
message: 'Privacy policy saved.',
version: versionOf(content),
});
});

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin';
import { picksFor } from '@/lib/admin/proofing-view';
import { immich } from '@/lib/immich';
import { getSessionById } from '@/lib/proofSessions';
import {
exportContentType,
exportExtension,
formatExport,
type ExportFormat,
} from '@/lib/proofExport';
import { contentDisposition, safeDownloadName } from '@/lib/downloadName';
const FORMATS: ExportFormat[] = ['lightroom', 'txt', 'csv'];
/** GET ?format=lightroom|txt|csv — the selection as a file. */
export const GET = withAdmin(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const requested = request.nextUrl.searchParams.get('format') as ExportFormat | null;
const format = requested && FORMATS.includes(requested) ? requested : 'lightroom';
const session = await getSessionById(id);
if (!session) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const album = await immich.getProofingAlbum(session.albumId).catch(() => null);
if (!album) {
return NextResponse.json({ error: 'The album is not available in Immich' }, { status: 502 });
}
const body = formatExport(picksFor(session, album), format);
const name = `${safeDownloadName(`${session.clientName} ${album.albumName}`, 'selection')}.${exportExtension(format)}`;
return new NextResponse(body, {
headers: {
'Content-Type': exportContentType(format),
'Content-Disposition': contentDisposition(name),
'Cache-Control': 'private, no-store',
},
});
},
);

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin';
import { picksFor, toAdminSession } from '@/lib/admin/proofing-view';
import { immich } from '@/lib/immich';
import {
deleteSession,
getSessionById,
ProofError,
sessionPatchSchema,
updateSession,
} from '@/lib/proofSessions';
type Context = { params: Promise<{ id: string }> };
/** GET: one link with its selected photos. */
export const GET = withAdmin(async (_request: NextRequest, { params }: Context) => {
const { id } = await params;
const session = await getSessionById(id);
if (!session) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const album = await immich.getProofingAlbum(session.albumId).catch(() => null);
return NextResponse.json({
session: toAdminSession(session, album),
picks: picksFor(session, album),
});
});
/** PATCH: rename, change expiry or downloads, reopen, reset the download count. */
export const PATCH = withAdmin(async (request: NextRequest, { params }: Context) => {
const { id } = await params;
const parsed = sessionPatchSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
try {
const session = await updateSession(id, parsed.data);
const album = await immich.getProofingAlbum(session.albumId).catch(() => null);
return NextResponse.json({ session: toAdminSession(session, album) });
} catch (err) {
if (err instanceof ProofError)
return NextResponse.json({ error: err.message }, { status: 404 });
throw err;
}
});
/** DELETE: revoke the link. The client URL stops working immediately. */
export const DELETE = withAdmin(async (_request: NextRequest, { params }: Context) => {
const { id } = await params;
try {
await deleteSession(id);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof ProofError)
return NextResponse.json({ error: err.message }, { status: 404 });
throw err;
}
});

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin';
import { loadAlbums, toAdminSession } from '@/lib/admin/proofing-view';
import { immich } from '@/lib/immich';
import { createSession, listSessions, ProofError, sessionInputSchema } from '@/lib/proofSessions';
import { webhookUrl } from '@/lib/proofWebhook';
/** GET: every proofing link, newest first. */
export const GET = withAdmin(async () => {
const sessions = await listSessions();
const albums = await loadAlbums(sessions.map((s) => s.albumId));
const list = sessions
.map((s) => toAdminSession(s, albums.get(s.albumId) ?? null))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return NextResponse.json({ sessions: list, webhookConfigured: webhookUrl() !== null });
});
/** POST: create a proofing link for one client and one album. */
export const POST = withAdmin(async (request: NextRequest) => {
const parsed = sessionInputSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
// Refuse a link to an album Immich does not have, rather than hand out a 404.
const album = await immich.getProofingAlbum(parsed.data.albumId).catch(() => null);
if (!album) {
return NextResponse.json({ error: 'Immich has no album with that ID' }, { status: 400 });
}
try {
const session = await createSession(parsed.data);
return NextResponse.json({ session: toAdminSession(session, album) }, { status: 201 });
} catch (err) {
if (err instanceof ProofError) {
return NextResponse.json({ error: err.message }, { status: 409 });
}
throw err;
}
});

View File

@@ -1,28 +1,48 @@
import { NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin';
import { revalidatePath } from 'next/cache';
import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service';
import {
readSettingsYaml,
readSettingsYamlVersioned,
writeSettingsYaml,
} from '@/lib/admin/yaml-service';
import {
VersionConflictError,
baseVersionFrom,
conflictResponse,
etag,
} from '@/lib/admin/contentVersion';
import { invalidateConfigCache, getConfigOrNull } from '@/lib/config';
import { immich } from '@/lib/immich';
import { validateSettings } from '@/lib/config/settingsSchema';
import { hashPasswordKeys } from '@/lib/admin/passwordHashing';
import { keepLockedValues, resolveEnvLocks } from '@/lib/admin/envLocks';
import { env } from '@/lib/env';
import { validateSettingValues } from '@/lib/config/settingValues';
const SITE_PASSWORD_KEY = new Set(['sitePassword']);
import type { SettingsYaml } from '@/lib/config/schema';
/** GET: Read current settings.yaml config. */
export const GET = withAdmin(async () => {
const settings = await readSettingsYaml();
const { data: settings, version } = await readSettingsYamlVersioned();
const config = getConfigOrNull();
return NextResponse.json({
settings: settings || {},
// The resolved value and its origin, so the panel can say when what it
// shows came from SITE_URL rather than from the field itself (#472).
siteUrl: {
effective: config?.siteUrl ?? null,
source: config?.siteUrlSource ?? 'none',
return NextResponse.json(
{
settings: settings || {},
// Sent back in If-Match on save (#601).
version,
// The resolved value and its origin, so the panel can say when what it
// shows came from SITE_URL rather than from the field itself (#472).
siteUrl: {
effective: config?.siteUrl ?? null,
source: config?.siteUrlSource ?? 'none',
},
// Fields an environment variable overrides, as path → variable name. Only
// the names: the values are secrets (a password, an ntfy topic) (#605).
envLocks: resolveEnvLocks(env),
},
});
{ headers: { ETag: etag(version) } },
);
});
/** PUT: Write settings.yaml config. */
@@ -34,12 +54,15 @@ export const PUT = withAdmin(async (request: Request) => {
// Checked before the write, not after: settings.yaml is read by every public
// page, so a malformed save is discovered by visitors rather than here.
// Then the values the site would silently ignore — an accent that is not
// hex, nine columns — which used to be saved with a success message.
const validation = validateSettings(body.settings);
if (!validation.ok) {
const fields = validation.ok ? validateSettingValues(body.settings) : validation.errors;
if (fields.length > 0) {
return NextResponse.json(
{
error: 'These settings could not be saved.',
fields: validation.errors,
fields,
},
{ status: 400 },
);
@@ -47,22 +70,27 @@ export const PUT = withAdmin(async (request: Request) => {
try {
// Only the top-level site password: settings.yaml has no other password.
const settings = await hashPasswordKeys(
body.settings as SettingsYaml,
SITE_PASSWORD_KEY,
await readSettingsYaml().catch(() => null),
);
await writeSettingsYaml(settings);
const stored = await readSettingsYaml().catch(() => null);
// A locked field keeps what the file holds: the environment wins anyway,
// so a changed value would be saved and then silently ignored (#605).
const incoming = keepLockedValues(body.settings as SettingsYaml, stored, resolveEnvLocks(env));
const settings = await hashPasswordKeys(incoming, SITE_PASSWORD_KEY, stored);
const version = await writeSettingsYaml(settings, baseVersionFrom(request));
invalidateConfigCache();
immich.invalidateAll();
// No immich.invalidateAll(): the Immich cache holds Immich's data only, and
// the gallery.yaml side (allowlist, titles, order) is applied per request
// from the new config. Clearing it made the next visitor refetch every
// album although nothing in Immich had changed.
revalidatePath('/', 'layout');
return NextResponse.json({
success: true,
message: 'Saved successfully. Backup of previous version created.',
// The stored site password, hashed, so the field can read "Protected".
sitePassword: settings.sitePassword,
version,
});
} catch (err) {
if (err instanceof VersionConflictError) return conflictResponse(err.currentVersion);
console.error('[Admin] Failed to write settings.yaml:', err);
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
}