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:
@@ -91,6 +91,42 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
|
||||
{ params: Promise.resolve({ album: 'tok' }) } as never,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'GET /api/download/[album]/[id]',
|
||||
call: async () =>
|
||||
(await import('../download/[album]/[id]/route')).GET(
|
||||
request('/api/download/tok/tok') as never,
|
||||
{ params: Promise.resolve({ album: 'tok', id: 'tok' }) } as never,
|
||||
),
|
||||
},
|
||||
// Client proofing links: the site password applies to clients as well.
|
||||
{
|
||||
name: 'PUT /api/proof/[token]/selection',
|
||||
call: async () =>
|
||||
(await import('../proof/[token]/selection/route')).PUT(
|
||||
new NextRequest('http://localhost/api/proof/tok/selection', {
|
||||
method: 'PUT',
|
||||
body: '{"selection":[]}',
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ token: 'tok' }) } as never,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'POST /api/proof/[token]/submit',
|
||||
call: async () =>
|
||||
(await import('../proof/[token]/submit/route')).POST(
|
||||
new NextRequest('http://localhost/api/proof/tok/submit', { method: 'POST' }) as never,
|
||||
{ params: Promise.resolve({ token: 'tok' }) } as never,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'GET /api/proof/[token]/archive',
|
||||
call: async () =>
|
||||
(await import('../proof/[token]/archive/route')).GET(
|
||||
request('/api/proof/tok/archive') as never,
|
||||
{ params: Promise.resolve({ token: 'tok' }) } as never,
|
||||
),
|
||||
},
|
||||
{
|
||||
// Stores whatever a visitor sends; a locked site accepts nothing from strangers.
|
||||
name: 'POST /api/contact',
|
||||
@@ -101,6 +137,68 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Routes that answer a locked site on purpose, keyed like the GATED names
|
||||
* (path under /api). Everything else under app/api must be in GATED.
|
||||
* /api/admin is absent: it has its own password and its own suite
|
||||
* (app/api/admin/__tests__/admin-guards.test.ts).
|
||||
*/
|
||||
const OPEN: Record<string, string> = {
|
||||
health: 'a container health probe runs without cookies',
|
||||
auth: 'the login endpoint cannot sit behind the lock it opens',
|
||||
'fonts/css': 'the gate page is set in the theme fonts',
|
||||
'fonts/file/[name]': 'the gate page is set in the theme fonts',
|
||||
favicon: 'the gate page shows the site icon',
|
||||
install: 'the first-run wizard; setup-token gated, refuses once installed',
|
||||
'install/albums': 'the first-run wizard; setup-token gated, refuses once installed',
|
||||
webhook: 'server-to-server from Immich, HMAC-verified',
|
||||
'analytics/track': 'counts a view of the gate itself; returns no content',
|
||||
};
|
||||
|
||||
/** `GET /api/download/[album]/[id]` → `download/[album]/[id]`. */
|
||||
const routeDir = (name: string) => name.replace(/^[A-Z]+ \/api\//, '');
|
||||
|
||||
describe('site gate coverage', () => {
|
||||
it('has a GATED or OPEN row for every public route module under app/api', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const apiDir = path.join(process.cwd(), 'app/api');
|
||||
|
||||
const found: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '__tests__') continue;
|
||||
walk(full);
|
||||
} else if (/^route\.tsx?$/.test(entry.name)) {
|
||||
found.push(path.relative(apiDir, path.dirname(full)).replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(apiDir);
|
||||
|
||||
const covered = new Set([...GATED.map((r) => routeDir(r.name)), ...Object.keys(OPEN)]);
|
||||
const uncovered = found.filter((p) => !p.startsWith('admin/') && !covered.has(p));
|
||||
|
||||
expect(uncovered, `Public API routes with no site-gate row: ${uncovered.join(', ')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('lists no route in OPEN that does not exist', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const missing = Object.keys(OPEN).filter((dir) => {
|
||||
const base = path.join(process.cwd(), 'app/api', dir);
|
||||
return (
|
||||
!fs.existsSync(path.join(base, 'route.ts')) && !fs.existsSync(path.join(base, 'route.tsx'))
|
||||
);
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public content routes behind a locked site', () => {
|
||||
beforeEach(() => {
|
||||
config.sitePassword = 'letmein';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
105
app/api/admin/__tests__/assets-search.test.ts
Normal file
105
app/api/admin/__tests__/assets-search.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
236
app/api/admin/__tests__/concurrent-edits.test.ts
Normal file
236
app/api/admin/__tests__/concurrent-edits.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
app/api/admin/__tests__/page-menu-refs.test.ts
Normal file
108
app/api/admin/__tests__/page-menu-refs.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
92
app/api/admin/__tests__/settings-env-locks.test.ts
Normal file
92
app/api/admin/__tests__/settings-env-locks.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
161
app/api/admin/pages/[slug]/route.ts
Normal file
161
app/api/admin/pages/[slug]/route.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
57
app/api/admin/pages/route.ts
Normal file
57
app/api/admin/pages/route.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
40
app/api/admin/proofing/[id]/export/route.ts
Normal file
40
app/api/admin/proofing/[id]/export/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
59
app/api/admin/proofing/[id]/route.ts
Normal file
59
app/api/admin/proofing/[id]/route.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
43
app/api/admin/proofing/route.ts
Normal file
43
app/api/admin/proofing/route.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -108,6 +108,28 @@ describe('POST /api/analytics/track', () => {
|
||||
expect(Object.keys(data.days[dateKey].pages)).toEqual(['/journal']);
|
||||
});
|
||||
|
||||
it('records a client proofing link without its token', async () => {
|
||||
// The token in /proof/<token> is the client's only credential; the counter
|
||||
// must not copy it into a file that is neither 0600 nor meant to hold one.
|
||||
const token = 'AbCdEfGhIjKlMnOpQrStUvWxYz012345';
|
||||
for (const path of [`/proof/${token}`, `/proof/${token}/`, `/proof/${token}?x=1`]) {
|
||||
await post({ path });
|
||||
|
||||
const data = savedData();
|
||||
const [dateKey] = Object.keys(data.days);
|
||||
expect(JSON.stringify(data), path).not.toContain(token);
|
||||
expect(data.days[dateKey].pages, path).toEqual({ '/proof': 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves paths that merely start with "proof" alone', async () => {
|
||||
await post({ path: '/proofreading' });
|
||||
|
||||
const data = savedData();
|
||||
const [dateKey] = Object.keys(data.days);
|
||||
expect(Object.keys(data.days[dateKey].pages)).toEqual(['/proofreading']);
|
||||
});
|
||||
|
||||
it('caps distinct page keys per day without dropping the pageview itself', async () => {
|
||||
const dateKey = new Date().toISOString().split('T')[0];
|
||||
const pages: Record<string, number> = {};
|
||||
|
||||
@@ -23,6 +23,16 @@ const MAX_PAGES_PER_DAY = 500;
|
||||
/** Well past any real route on this app; anything longer is not a path someone navigated to. */
|
||||
const MAX_PATH_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* The path as it is counted. A client proofing link's path carries its token
|
||||
* (/proof/<token>), which is the client's only credential: proofing.json is
|
||||
* written 0600 for that reason, and this file is not. Every link is counted as
|
||||
* `/proof`, so the counter never holds a working link.
|
||||
*/
|
||||
function countedPath(pagePath: string): string {
|
||||
return /^\/proof(?:\/|$)/.test(pagePath) ? '/proof' : pagePath;
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
summary: {
|
||||
totalViews: number;
|
||||
@@ -92,7 +102,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const rawPath = typeof body?.path === 'string' ? body.path : '/';
|
||||
const pagePath = (rawPath.split('?')[0].slice(0, MAX_PATH_LENGTH) || '/') as string;
|
||||
const pagePath = countedPath(rawPath.split('?')[0].slice(0, MAX_PATH_LENGTH) || '/');
|
||||
const userAgent = req.headers.get('user-agent') || '';
|
||||
const isMobile = /mobile|iphone|ipad|android/i.test(userAgent);
|
||||
const deviceType = isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
61
app/api/auth/__tests__/route.test.ts
Normal file
61
app/api/auth/__tests__/route.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* A slug without a password must be indistinguishable from a wrong password.
|
||||
* A separate "not password-protected" answer let anyone probing slugs tell
|
||||
* which ones exist behind a lock — drafts and offline subpages included, whose
|
||||
* pages answer 404 and give nothing away themselves.
|
||||
*/
|
||||
|
||||
const config = {
|
||||
authSecret: 'test-secret-that-is-at-least-32-chars-long',
|
||||
subpages: [
|
||||
{ slug: 'private', name: 'Private', password: 'letmein', albumIds: [] },
|
||||
{ slug: 'public', name: 'Public', albumIds: [] },
|
||||
],
|
||||
albumPasswords: {},
|
||||
sitePassword: undefined,
|
||||
};
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => config,
|
||||
getConfigOrNull: () => config,
|
||||
}));
|
||||
|
||||
const { POST } = await import('../route');
|
||||
|
||||
async function attempt(slug: string, password: string, type = 'subpage') {
|
||||
// Eight attempts in all, inside the route's limit of ten per minute.
|
||||
const res = await POST(
|
||||
new NextRequest('http://localhost/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slug, password, type }),
|
||||
}),
|
||||
);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
describe('POST /api/auth', () => {
|
||||
it('unlocks a protected subpage with its password', async () => {
|
||||
const res = await attempt('private', 'letmein');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('answers a slug without a password like a wrong password', async () => {
|
||||
const wrong = await attempt('private', 'nope');
|
||||
expect(wrong.status).toBe(401);
|
||||
|
||||
for (const [slug, type] of [
|
||||
['public', 'subpage'],
|
||||
['does-not-exist', 'subpage'],
|
||||
['does-not-exist', 'journal'],
|
||||
['does-not-exist', 'page'],
|
||||
['00000000-0000-0000-0000-000000000000', 'album'],
|
||||
['__site__', 'site'],
|
||||
]) {
|
||||
expect(await attempt(slug, 'nope', type)).toEqual(wrong);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticate, isHttpsRequest, isProtected, type ProtectedType } from '@/lib/auth';
|
||||
import { authenticate, isHttpsRequest, type ProtectedType } from '@/lib/auth';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
|
||||
/** Tight limit for auth attempts — 10 per minute per IP. */
|
||||
@@ -49,28 +49,16 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 });
|
||||
}
|
||||
|
||||
const TYPES: ProtectedType[] = ['subpage', 'album', 'journal', 'site'];
|
||||
const TYPES: ProtectedType[] = ['subpage', 'album', 'journal', 'page', 'site'];
|
||||
if (!TYPES.includes(type)) {
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isProtected(slug, type)) {
|
||||
const typeLabel =
|
||||
type === 'journal'
|
||||
? 'Journal entry'
|
||||
: type === 'subpage'
|
||||
? 'Subpage'
|
||||
: type === 'site'
|
||||
? 'Site'
|
||||
: 'Album';
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${typeLabel} is not password-protected`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// A key with no password (or none at all) answers exactly like a wrong
|
||||
// password: `authenticate()` returns null for both. A distinct "not
|
||||
// password-protected" reply told anyone probing slugs which ones hold a
|
||||
// locked entry — including drafts and offline subpages, whose pages answer
|
||||
// 404 and so give nothing away themselves.
|
||||
const setCookie = await authenticate(slug, password, type, isHttpsRequest(request));
|
||||
if (!setCookie) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
|
||||
@@ -32,12 +32,14 @@ import { GET, POST } from '../route';
|
||||
import { getConfig, getConfigOrNull } from '@/lib/config';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { getClientIp } from '@/lib/rate-limit';
|
||||
|
||||
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockConfigOrNull = getConfigOrNull as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockDecode = decodeAssetId as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockClientIp = getClientIp as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const ALBUM = {
|
||||
id: 'album-uuid',
|
||||
@@ -269,6 +271,75 @@ describe('refusals', () => {
|
||||
expect(html).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
/**
|
||||
* Next rebuilds `nextUrl` from its own bind address, so behind
|
||||
* `next start -H 127.0.0.1` or a reverse proxy it reads `localhost` while
|
||||
* the visitor's Referer names the host they typed. Comparing against
|
||||
* `nextUrl.origin` sent every visitor home.
|
||||
*/
|
||||
it('follows the Referer when Next knows itself as localhost but was asked as another host', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const req = new NextRequest('http://localhost:3598/api/download/album-token/archive', {
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
Host: '127.0.0.1:3598',
|
||||
Referer: 'http://127.0.0.1:3598/polen/krakau',
|
||||
},
|
||||
});
|
||||
const html = await (await GET(req, params)).text();
|
||||
expect(html).toContain('href="/polen/krakau"');
|
||||
});
|
||||
|
||||
it('does not follow a Referer whose path would read as a host', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const html = await (await GET(browsesHtml('http://localhost//evil.example/x'), params)).text();
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('ignores X-Forwarded-Host unless a proxy is trusted', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const req = new NextRequest('http://localhost/api/download/album-token/archive', {
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
'X-Forwarded-Host': 'evil.example',
|
||||
Referer: 'https://evil.example/phish',
|
||||
},
|
||||
});
|
||||
const html = await (await GET(req, params)).text();
|
||||
expect(html).toContain('href="/"');
|
||||
});
|
||||
|
||||
/**
|
||||
* The page used to be browser-default: white, blue link, system font — in
|
||||
* the middle of a dark gallery. It now carries the site's colour mode and
|
||||
* accent in an inline stylesheet, and a policy that allows nothing else.
|
||||
*/
|
||||
it("wears the site's colour mode and accent, under a policy that allows only its style", async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
mockConfigOrNull.mockReturnValue({
|
||||
lang: 'de',
|
||||
colorMode: 'dark',
|
||||
theme: { preset: 'studio-modern', accent: '#e60012' },
|
||||
});
|
||||
const res = await GET(browsesHtml(), params);
|
||||
const html = await res.text();
|
||||
expect(html).toContain('<style>');
|
||||
expect(html).toContain('color-scheme:dark;--bg:#121212');
|
||||
expect(html).toContain('--accent:#e60012');
|
||||
expect(html).not.toContain('style="');
|
||||
expect(res.headers.get('content-security-policy')).toBe(
|
||||
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
|
||||
);
|
||||
});
|
||||
|
||||
it("follows the visitor's OS when the site's mode is auto", async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
mockConfigOrNull.mockReturnValue({ colorMode: 'auto' });
|
||||
const html = await (await GET(browsesHtml(), params)).text();
|
||||
expect(html).toContain('@media (prefers-color-scheme: light){:root{color-scheme:light;');
|
||||
});
|
||||
|
||||
it('keeps JSON for an API caller', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const res = await GET(getReq(), params);
|
||||
@@ -311,3 +382,51 @@ describe('abort', () => {
|
||||
expect(mockStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The album archive and the proofing archive draw on one pool of slots per
|
||||
* client, so holding two album ZIPs open closes the proofing route as well.
|
||||
*/
|
||||
describe('concurrent archives', () => {
|
||||
it('shares one per-client cap with the proofing archive route', async () => {
|
||||
mockClientIp.mockReturnValue('198.51.100.7');
|
||||
// Originals that never finish, so both archives stay open.
|
||||
mockStream.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('first-chunk'));
|
||||
},
|
||||
}),
|
||||
contentType: 'image/jpeg',
|
||||
contentLength: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await GET(getReq(), params);
|
||||
const second = await POST(postReq(['asset-token-1']), params);
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
|
||||
const third = await GET(getReq(), params);
|
||||
expect(third.status).toBe(429);
|
||||
expect(third.headers.get('retry-after')).toBeTruthy();
|
||||
|
||||
const { GET: proofArchive } = await import('@/app/api/proof/[token]/archive/route');
|
||||
const proof = await proofArchive(
|
||||
new NextRequest('http://localhost/api/proof/tok/archive?scope=album'),
|
||||
{ params: Promise.resolve({ token: 'tok' }) },
|
||||
);
|
||||
expect(proof.status).toBe(429);
|
||||
|
||||
// Closing one download frees its slot for the next.
|
||||
await first.body!.cancel();
|
||||
await vi.waitFor(async () => {
|
||||
const again = await GET(getReq(), params);
|
||||
expect(again.status).toBe(200);
|
||||
await again.body!.cancel();
|
||||
});
|
||||
await second.body!.cancel();
|
||||
mockClientIp.mockReturnValue('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,18 +16,15 @@
|
||||
* in a blob.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Readable } from 'node:stream';
|
||||
import archiver from 'archiver';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { immich, ImmichUnavailableError, type ImmichAsset } from '@/lib/immich';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
import { isAlbumReachable, siteLockResponse } from '@/lib/auth';
|
||||
import { contentDisposition, safeDownloadName } from '@/lib/downloadName';
|
||||
import { getDictionary } from '@/lib/i18n';
|
||||
import { getLocale, getServerDictionary } from '@/lib/i18n/server';
|
||||
import { refusal, withArchiveSlot, type ArchiveStreamer } from '@/lib/zipArchive';
|
||||
import { readBodyCapped } from '@/lib/requestBody';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -52,146 +49,6 @@ const MAX_SELECTION_ASSETS = 1000;
|
||||
*/
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
/** Escape a value interpolated into the refusal page. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) =>
|
||||
char === '&'
|
||||
? '&'
|
||||
: char === '<'
|
||||
? '<'
|
||||
: char === '>'
|
||||
? '>'
|
||||
: char === '"'
|
||||
? '"'
|
||||
: ''',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal, rendered for whoever asked.
|
||||
*
|
||||
* A download begins as a navigation — the header link, or the proofing modal's
|
||||
* form POST — and only a success carries `Content-Disposition: attachment`. A
|
||||
* bare JSON body would therefore replace the gallery with
|
||||
* `{"error":"Too many requests"}` on a rate limit, which is the likely failure
|
||||
* (5 rpm, and the ZIP shows nothing until the first byte, so people click
|
||||
* again). Browsers get a page with a way back; callers asking for JSON keep it.
|
||||
*
|
||||
* The page speaks the site's language; the JSON stays English, like every other
|
||||
* API error.
|
||||
*/
|
||||
function refusal(
|
||||
request: NextRequest,
|
||||
status: number,
|
||||
reason: RefusalReason,
|
||||
retryAfter?: number,
|
||||
): NextResponse {
|
||||
const headers: Record<string, string> = { 'Cache-Control': 'no-store' };
|
||||
if (retryAfter) headers['Retry-After'] = String(retryAfter);
|
||||
|
||||
if (!(request.headers.get('accept') ?? '').includes('text/html')) {
|
||||
return NextResponse.json({ error: getDictionary('en').download[reason] }, { status, headers });
|
||||
}
|
||||
|
||||
const t = getServerDictionary().download;
|
||||
headers['Content-Type'] = 'text/html; charset=utf-8';
|
||||
return new NextResponse(
|
||||
`<!doctype html><html lang="${getLocale()}"><head><meta charset="utf-8">` +
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
||||
`<title>${escapeHtml(t.unavailableTitle)}</title></head>` +
|
||||
'<body style="font-family:system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1.5rem;line-height:1.6">' +
|
||||
`<h1 style="font-size:1.25rem">${escapeHtml(t.unavailableTitle)}</h1>` +
|
||||
`<p>${escapeHtml(t[reason])}</p>` +
|
||||
`<p><a href="${escapeHtml(backHref(request))}">${escapeHtml(t.back)}</a></p>` +
|
||||
'</body></html>',
|
||||
{ status, headers },
|
||||
);
|
||||
}
|
||||
|
||||
type RefusalReason = 'notAvailable' | 'rateLimited' | 'immichUnavailable';
|
||||
|
||||
/**
|
||||
* Where the refusal page's link leads: the page the download was started from,
|
||||
* so a visitor lands back on the album rather than the home page. Only a
|
||||
* same-origin `Referer` is followed — anything else would turn this page into an
|
||||
* open redirect with our name on it.
|
||||
*/
|
||||
function backHref(request: NextRequest): string {
|
||||
const referer = request.headers.get('referer');
|
||||
if (!referer) return '/';
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
if (url.origin !== request.nextUrl.origin) return '/';
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique, ZIP-safe entry name. Originals can collide ("IMG_0001.jpg" from two
|
||||
* cards), and a ZIP with duplicate entry names is ambiguous to unzip.
|
||||
*/
|
||||
function uniqueEntryName(raw: string | undefined, used: Set<string>): string {
|
||||
const base = safeDownloadName(raw);
|
||||
let name = base;
|
||||
let n = 2;
|
||||
while (used.has(name)) {
|
||||
const dot = base.lastIndexOf('.');
|
||||
name = dot > 0 ? `${base.slice(0, dot)}-${n}${base.slice(dot)}` : `${base}-${n}`;
|
||||
n++;
|
||||
}
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one entry, resolving once it has been written (or the archive has been
|
||||
* torn down because the visitor left).
|
||||
*
|
||||
* archiver processes entries strictly one at a time — that is what keeps memory
|
||||
* flat — but `append()` only *queues* them. Firing every `append` up front (and
|
||||
* so opening every upstream request at once) is what made a large album's later
|
||||
* originals sit long enough to time out and truncate the ZIP. Awaiting the
|
||||
* `entry` event holds the loop until the previous file has finished, so only
|
||||
* one original is ever in flight. `close` resolves too, so an aborted download
|
||||
* stops the loop rather than hanging on a file nothing will read.
|
||||
*
|
||||
* On `close` and `error` the source is destroyed as well. archiver does not do
|
||||
* that for a queued or half-read entry, and the upstream body would otherwise
|
||||
* hold its socket out of undici's pool until GC finalises it (#635).
|
||||
*/
|
||||
function appendEntry(archive: archiver.Archiver, source: Readable, name: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
archive.off('entry', onEntry);
|
||||
archive.off('error', onError);
|
||||
archive.off('close', onClose);
|
||||
};
|
||||
const onEntry = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
// `close` fires when the response is consumed or aborted. Resolving on it
|
||||
// lets the loop see `archive.destroyed` and stop, rather than hanging on a
|
||||
// file nothing will read.
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
source.destroy();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
source.destroy();
|
||||
reject(error);
|
||||
};
|
||||
archive.once('entry', onEntry);
|
||||
archive.once('error', onError);
|
||||
archive.once('close', onClose);
|
||||
archive.append(source, { name });
|
||||
});
|
||||
}
|
||||
|
||||
/** A refusal to short-circuit with, or a decoded album ready to stream. */
|
||||
type ResolvedAlbum = { error: Response } | { albumName: string; assets: ImmichAsset[] };
|
||||
|
||||
@@ -248,62 +105,6 @@ async function resolveAlbum(
|
||||
return { albumName: album.albumName, assets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `assets` as a ZIP of originals.
|
||||
*
|
||||
* archiver writes data descriptors, so entry sizes are never known up front and
|
||||
* memory stays flat no matter how large the album is. The loop pulls one
|
||||
* original at a time and stops as soon as the response is gone.
|
||||
*/
|
||||
function streamArchive(albumName: string, assets: ImmichAsset[]): NextResponse {
|
||||
const archive = archiver('zip', { store: true });
|
||||
archive.on('error', (err) => {
|
||||
// A visitor cancelling the download is not a failure worth a log line.
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error(`[Download] Archive stream failed:`, err);
|
||||
});
|
||||
|
||||
const body = Readable.toWeb(archive) as unknown as ReadableStream;
|
||||
|
||||
// Fill the archive in the background: the response has to go out first so the
|
||||
// browser starts reading, and each originals fetch is awaited in turn.
|
||||
void (async () => {
|
||||
try {
|
||||
const used = new Set<string>();
|
||||
for (const asset of assets) {
|
||||
// The visitor left (or the archive failed): stop pulling originals.
|
||||
if (archive.destroyed) break;
|
||||
const result = await immich.streamAsset(asset.id, 'original');
|
||||
if (!result) continue;
|
||||
if (archive.destroyed) {
|
||||
// Left while the headers were on their way: release the body unread.
|
||||
await result.stream.cancel();
|
||||
break;
|
||||
}
|
||||
const nodeStream = Readable.fromWeb(
|
||||
result.stream as unknown as import('node:stream/web').ReadableStream,
|
||||
);
|
||||
await appendEntry(archive, nodeStream, uniqueEntryName(asset.originalFileName, used));
|
||||
}
|
||||
if (!archive.destroyed) await archive.finalize();
|
||||
} catch (err) {
|
||||
archive.destroy(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
})();
|
||||
|
||||
const zipName = `${safeDownloadName(albumName, 'album')}.zip`;
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': contentDisposition(zipName),
|
||||
// Private: authorised per visitor, so a shared cache must not hand the
|
||||
// archive to the next one.
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the POST body with a hard cap, then pull the token list out of it.
|
||||
*
|
||||
@@ -342,41 +143,16 @@ async function readSelectedTokens(request: NextRequest): Promise<string[] | null
|
||||
return tokens.length > 0 ? tokens : null;
|
||||
}
|
||||
|
||||
/** Read a request body as text, aborting once it exceeds `limit` bytes. */
|
||||
async function readBodyCapped(request: NextRequest, limit: number): Promise<string | null> {
|
||||
const stream = request.body;
|
||||
if (!stream) return '';
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > limit) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ album: string }> },
|
||||
) {
|
||||
const { album } = await params;
|
||||
const resolved = await resolveAlbum(request, { album });
|
||||
if ('error' in resolved) return resolved.error;
|
||||
return streamArchive(resolved.albumName, resolved.assets);
|
||||
return withArchiveSlot(request, async (stream) => {
|
||||
const resolved = await resolveAlbum(request, { album });
|
||||
if ('error' in resolved) return resolved.error;
|
||||
return stream(resolved.albumName, resolved.assets);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
@@ -384,6 +160,15 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ album: string }> },
|
||||
) {
|
||||
const { album } = await params;
|
||||
return withArchiveSlot(request, (stream) => streamSelection(request, album, stream));
|
||||
}
|
||||
|
||||
/** The POST handler's body, run inside the client's archive slot. */
|
||||
async function streamSelection(
|
||||
request: NextRequest,
|
||||
album: string,
|
||||
stream: ArchiveStreamer,
|
||||
): Promise<Response> {
|
||||
const resolved = await resolveAlbum(request, { album });
|
||||
if ('error' in resolved) return resolved.error;
|
||||
|
||||
@@ -418,5 +203,5 @@ export async function POST(
|
||||
return refusal(request, 404, 'notAvailable');
|
||||
}
|
||||
|
||||
return streamArchive(resolved.albumName, selected);
|
||||
return stream(resolved.albumName, selected);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { immich, ImmichUnavailableError } from '@/lib/immich';
|
||||
import { isPublishedAsset } from '@/lib/publishedAssets';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
@@ -52,6 +54,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only what the site currently shows (GHSA-gfh4-6275-9gqv): a token stays
|
||||
// decodable forever, so an unpublished album or a revoked proofing link
|
||||
// would otherwise keep serving. Signed-in admins pass for previews.
|
||||
if (!(await isPublishedAsset(assetId)) && !(await isAdminAuthenticated())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not found' },
|
||||
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
let asset;
|
||||
try {
|
||||
asset = await immich.getAssetInfo(assetId);
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
* without exposing the Immich server or API key.
|
||||
*
|
||||
* Usage: GET /api/image/:token?size=thumbnail|preview|original
|
||||
* GET /api/image/:token?w=640&q=75 (for next/image loader)
|
||||
* GET /api/image/:token?size=preview&w=1440 (next/image loader, lib/imageSize.ts)
|
||||
* The :token is an encoded asset ID (not a raw UUID).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { immich, ImmichUnavailableError } from '@/lib/immich';
|
||||
import { resolveImageSize } from '@/lib/imageSize';
|
||||
import { isPublishedAsset } from '@/lib/publishedAssets';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
@@ -69,6 +71,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only what the site currently shows (GHSA-gfh4-6275-9gqv): a token stays
|
||||
// decodable forever, so an unpublished album or a revoked proofing link
|
||||
// would otherwise keep serving. Signed-in admins pass for previews.
|
||||
if (!(await isPublishedAsset(assetId)) && !(await isAdminAuthenticated())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not found' },
|
||||
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
const size = resolveImageSize(
|
||||
request.nextUrl.searchParams.get('size'),
|
||||
request.nextUrl.searchParams.get('w'),
|
||||
|
||||
@@ -4,6 +4,14 @@ import { GET } from '../[id]/route';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { immich, ImmichUnavailableError } from '@/lib/immich';
|
||||
|
||||
const published = vi.hoisted(() => ({ value: true }));
|
||||
vi.mock('@/lib/publishedAssets', () => ({
|
||||
isPublishedAsset: async () => published.value,
|
||||
}));
|
||||
vi.mock('@/lib/admin/auth', () => ({
|
||||
isAdminAuthenticated: async () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/immich', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/immich')>('@/lib/immich');
|
||||
return {
|
||||
@@ -53,6 +61,18 @@ describe('GET /api/image/[id]', () => {
|
||||
expect(mockStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an asset the site no longer shows, without caching the refusal', async () => {
|
||||
published.value = false;
|
||||
try {
|
||||
const res = await call(encodeAssetId(ASSET_ID));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(mockStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
published.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
it('serves a valid token', async () => {
|
||||
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
|
||||
const res = await call(encodeAssetId(ASSET_ID));
|
||||
|
||||
82
app/api/og/__tests__/route.test.tsx
Normal file
82
app/api/og/__tests__/route.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* The share card prints its URL's text on the site's own domain. Only text
|
||||
* the site signed itself (lib/ogImage.ts) may reach it; any other URL gets the
|
||||
* plain site card.
|
||||
*/
|
||||
|
||||
const config = {
|
||||
authSecret: 'test-secret-that-is-at-least-32-chars-long',
|
||||
siteTitle: 'Jane Doe Photography',
|
||||
theme: { accent: '#e60012' },
|
||||
sitePassword: undefined,
|
||||
};
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => config,
|
||||
getConfigOrNull: () => config,
|
||||
}));
|
||||
|
||||
let rendered: ReactNode = null;
|
||||
vi.mock('next/og', () => ({
|
||||
ImageResponse: class {
|
||||
status = 200;
|
||||
constructor(element: ReactNode) {
|
||||
rendered = element;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { GET } = await import('../route');
|
||||
const { ogImageUrl } = await import('@/lib/ogImage');
|
||||
|
||||
/** Every non-empty string in the rendered card, in order. */
|
||||
function textOf(node: ReactNode): string[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return [];
|
||||
if (node === '') return [];
|
||||
if (typeof node === 'string' || typeof node === 'number') return [String(node)];
|
||||
if (Array.isArray(node)) return node.flatMap(textOf);
|
||||
if (typeof node === 'object' && 'props' in node) {
|
||||
return textOf((node.props as { children?: ReactNode }).children);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function cardText(path: string): Promise<string[]> {
|
||||
rendered = null;
|
||||
const res = await GET(new NextRequest(`http://localhost${path}`) as never);
|
||||
expect(res.status).toBe(200);
|
||||
return textOf(rendered);
|
||||
}
|
||||
|
||||
describe('GET /api/og', () => {
|
||||
it('renders text the site signed', async () => {
|
||||
const text = await cardText(ogImageUrl('Iceland 2024', '42 photos'));
|
||||
expect(text).toEqual(['Iceland 2024', '42 photos']);
|
||||
});
|
||||
|
||||
it('ignores an unsigned title and shows the site title', async () => {
|
||||
const text = await cardText(
|
||||
`/api/og?title=${encodeURIComponent('Your account is locked — call +1 555 0100')}`,
|
||||
);
|
||||
expect(text).toEqual(['Jane Doe Photography']);
|
||||
});
|
||||
|
||||
it('ignores text whose signature belongs to other text', async () => {
|
||||
const url = new URL(ogImageUrl('Iceland 2024', '42 photos'), 'http://localhost');
|
||||
url.searchParams.set('subtitle', 'send your password to evil.example');
|
||||
const text = await cardText(url.pathname + url.search);
|
||||
expect(text).toEqual(['Jane Doe Photography']);
|
||||
});
|
||||
|
||||
it('keeps a signature intact across non-ASCII text and truncation', async () => {
|
||||
const long = '家族相册 🏔️ '.repeat(40);
|
||||
const text = await cardText(ogImageUrl(long));
|
||||
expect(text).toHaveLength(1);
|
||||
expect(text[0]).not.toBe('Jane Doe Photography');
|
||||
expect(Array.from(text[0]).length).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@
|
||||
* Uses next/og (ImageResponse) to create 1200×630 cards.
|
||||
* Reads accent color from theme config.
|
||||
*
|
||||
* GET /api/og?title=Album+Name&subtitle=12+photos
|
||||
* GET /api/og?title=Album+Name&subtitle=12+photos&sig=…
|
||||
*
|
||||
* Only text signed by `ogImageUrl()` (lib/ogImage.ts) is rendered; anything
|
||||
* else gets the plain site card, so the site cannot be made to print
|
||||
* arbitrary text under its own name.
|
||||
*/
|
||||
|
||||
import { ImageResponse } from 'next/og';
|
||||
@@ -11,6 +15,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
import { siteLockResponse } from '@/lib/auth';
|
||||
import { verifiedOgText } from '@/lib/ogImage';
|
||||
|
||||
/**
|
||||
* OG image rendering runs satori + resvg per request and each unique ?title
|
||||
@@ -22,7 +27,7 @@ const OG_RPM = 30;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ip = getClientIp(request);
|
||||
const { theme } = getConfig();
|
||||
const { theme, siteTitle, authSecret } = getConfig();
|
||||
|
||||
const { success, resetAt } = checkRateLimit(`og:${ip}`, OG_RPM);
|
||||
if (!success) {
|
||||
@@ -40,9 +45,12 @@ export async function GET(request: NextRequest) {
|
||||
const locked = siteLockResponse(request);
|
||||
if (locked) return locked;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const title = (searchParams.get('title') || 'Gallery').slice(0, 200);
|
||||
const subtitle = (searchParams.get('subtitle') || '').slice(0, 100);
|
||||
// The site title is already in the visitor's language, or falls back to the
|
||||
// locale's own word for "Gallery" (lib/config). It is also the card for any
|
||||
// URL whose text the site did not sign itself.
|
||||
const signed = verifiedOgText(request.nextUrl.searchParams, authSecret);
|
||||
const title = signed?.title || siteTitle || '';
|
||||
const subtitle = signed?.subtitle ?? '';
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
@@ -83,7 +91,8 @@ export async function GET(request: NextRequest) {
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
{/* Subtitle — printed as the locale spells it. A lowercase transform
|
||||
turned German "25 Fotos" into "25 fotos". */}
|
||||
{subtitle && (
|
||||
<div
|
||||
style={{
|
||||
@@ -92,7 +101,6 @@ export async function GET(request: NextRequest) {
|
||||
fontWeight: 300,
|
||||
marginTop: '16px',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'lowercase',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
72
app/api/proof/[token]/archive/route.ts
Normal file
72
app/api/proof/[token]/archive/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* GET /api/proof/:token/archive?scope=selection|album — the originals of a
|
||||
* client proofing link as a ZIP.
|
||||
*
|
||||
* What may be downloaded is the session's to say (`download: selection` or
|
||||
* `album`), every successful start counts against its `downloadLimit`, and
|
||||
* the selection ZIP is built from the saved selection, never from the request.
|
||||
* Streaming and the refusal page are shared with the album archive route.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { resolveProofAccess } from '@/lib/proofAccess';
|
||||
import { claimDownload, ProofError } from '@/lib/proofSessions';
|
||||
import { refusal, withArchiveSlot, type ArchiveStreamer } from '@/lib/zipArchive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Same budget as the album archive: a whole album of originals per request. */
|
||||
const ARCHIVE_RPM = 5;
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
// One slot pool with the album archive route, taken before the first Immich
|
||||
// request and before the download is counted against the link's limit.
|
||||
return withArchiveSlot(request, (stream) => streamProofArchive(request, token, stream));
|
||||
}
|
||||
|
||||
/** The handler's body, run inside the client's archive slot. */
|
||||
async function streamProofArchive(
|
||||
request: NextRequest,
|
||||
token: string,
|
||||
stream: ArchiveStreamer,
|
||||
): Promise<Response> {
|
||||
const access = await resolveProofAccess(request, token, 'proof-archive', ARCHIVE_RPM);
|
||||
if ('error' in access) {
|
||||
const status = access.error.status;
|
||||
if (status === 429) {
|
||||
const retry = Number(access.error.headers.get('Retry-After')) || undefined;
|
||||
return refusal(request, 429, 'rateLimited', retry);
|
||||
}
|
||||
if (status === 503) return refusal(request, 503, 'immichUnavailable', 30);
|
||||
if (status === 401) return access.error;
|
||||
return refusal(request, 404, 'notAvailable');
|
||||
}
|
||||
|
||||
const { session } = access;
|
||||
const scope = request.nextUrl.searchParams.get('scope') === 'album' ? 'album' : 'selection';
|
||||
if (session.download === 'none') return refusal(request, 404, 'notAvailable');
|
||||
if (scope === 'album' && session.download !== 'album') {
|
||||
return refusal(request, 404, 'notAvailable');
|
||||
}
|
||||
|
||||
const chosen = new Set(session.selection);
|
||||
const assets =
|
||||
scope === 'album' ? access.assets : access.assets.filter((asset) => chosen.has(asset.id));
|
||||
if (assets.length === 0) return refusal(request, 404, 'notAvailable');
|
||||
|
||||
try {
|
||||
await claimDownload(token, scope);
|
||||
} catch (err) {
|
||||
if (err instanceof ProofError && err.code === 'limit') {
|
||||
return refusal(request, 403, 'limitReached');
|
||||
}
|
||||
if (err instanceof ProofError) return refusal(request, 404, 'notAvailable');
|
||||
throw err;
|
||||
}
|
||||
|
||||
return stream(access.albumName, assets);
|
||||
}
|
||||
51
app/api/proof/[token]/selection/route.ts
Normal file
51
app/api/proof/[token]/selection/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* PUT /api/proof/:token/selection — replace a client's saved selection.
|
||||
*
|
||||
* Body: `{ "assets": [<asset token>, …] }`, the album's opaque asset tokens in
|
||||
* any order. Answers 409 once the selection has been submitted, which the page
|
||||
* turns into its locked state.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { decodeSelection, proofError, resolveProofAccess } from '@/lib/proofAccess';
|
||||
import { MAX_SELECTION, ProofError, saveSelection } from '@/lib/proofSessions';
|
||||
import { readJsonCapped } from '@/lib/requestBody';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Autosave fires once per burst of hearts; this is generous for one visitor. */
|
||||
const SELECTION_RPM = 60;
|
||||
|
||||
/** MAX_SELECTION tokens of ~70 characters each, with room to spare. */
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const access = await resolveProofAccess(request, token, 'proof-save', SELECTION_RPM);
|
||||
if ('error' in access) return access.error;
|
||||
|
||||
const body = (await readJsonCapped(request, MAX_BODY_BYTES)) as { assets?: unknown } | null;
|
||||
const assets = body?.assets;
|
||||
if (!Array.isArray(assets) || assets.length > MAX_SELECTION) {
|
||||
return proofError(400, 'Invalid selection');
|
||||
}
|
||||
const ids = decodeSelection(assets, access.assets);
|
||||
if (!ids) return proofError(400, 'Invalid selection');
|
||||
|
||||
try {
|
||||
const session = await saveSelection(token, ids);
|
||||
return NextResponse.json(
|
||||
{ ok: true, selected: session.selection.length },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ProofError && err.code === 'submitted') {
|
||||
return proofError(409, 'Selection already submitted');
|
||||
}
|
||||
if (err instanceof ProofError) return proofError(404, 'Not found');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
44
app/api/proof/[token]/submit/route.ts
Normal file
44
app/api/proof/[token]/submit/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* POST /api/proof/:token/submit — lock the client's selection and notify the
|
||||
* photographer.
|
||||
*
|
||||
* Idempotent: submitting twice answers 200 both times, and only the first call
|
||||
* fires the webhook.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proofError, resolveProofAccess } from '@/lib/proofAccess';
|
||||
import { ProofError, submitSelection } from '@/lib/proofSessions';
|
||||
import { notifySubmitted } from '@/lib/proofWebhook';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const SUBMIT_RPM = 10;
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const access = await resolveProofAccess(request, token, 'proof-submit', SUBMIT_RPM);
|
||||
if ('error' in access) return access.error;
|
||||
|
||||
try {
|
||||
const { session, firstSubmit } = await submitSelection(token);
|
||||
if (firstSubmit) {
|
||||
// Not awaited: a slow or broken webhook must not hold up the client.
|
||||
void notifySubmitted(session, access.albumName, getConfig().siteUrl);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: true, submittedAt: session.submittedAt },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ProofError && err.code === 'empty') {
|
||||
return proofError(400, 'Nothing selected');
|
||||
}
|
||||
if (err instanceof ProofError) return proofError(404, 'Not found');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
197
app/api/proof/__tests__/routes.test.ts
Normal file
197
app/api/proof/__tests__/routes.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* The public proofing routes against a real session store in a temp dir. Immich,
|
||||
* the asset tokens, the ZIP stream and the webhook are mocked; what is under
|
||||
* test is the authorisation: which token may change what, and when.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: vi.fn(() => ({ siteUrl: null })),
|
||||
getConfigOrNull: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('@/lib/tokens', () => ({
|
||||
decodeAssetId: vi.fn((token: string) => (token.startsWith('tok-') ? token.slice(4) : null)),
|
||||
}));
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { getProofingAlbum: vi.fn() },
|
||||
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
|
||||
}));
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
|
||||
getClientIp: vi.fn(() => '127.0.0.1'),
|
||||
retryAfterSeconds: vi.fn(() => 60),
|
||||
}));
|
||||
vi.mock('@/lib/auth', () => ({ siteLockResponse: vi.fn(() => null) }));
|
||||
vi.mock('@/lib/proofWebhook', () => ({ notifySubmitted: vi.fn(async () => {}) }));
|
||||
vi.mock('@/lib/zipArchive', () => ({
|
||||
withArchiveSlot: vi.fn(
|
||||
(
|
||||
_req: unknown,
|
||||
handler: (stream: (name: string, assets: { id: string }[]) => Response) => unknown,
|
||||
) => handler((name, assets) => Response.json({ name, ids: assets.map((a) => a.id) })),
|
||||
),
|
||||
refusal: vi.fn((_req: unknown, status: number, reason: string) =>
|
||||
Response.json({ reason }, { status }),
|
||||
),
|
||||
}));
|
||||
|
||||
import { PUT } from '../[token]/selection/route';
|
||||
import { POST } from '../[token]/submit/route';
|
||||
import { GET } from '../[token]/archive/route';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { notifySubmitted } from '@/lib/proofWebhook';
|
||||
import { siteLockResponse } from '@/lib/auth';
|
||||
import { createSession, getSessionById, sessionInputSchema } from '@/lib/proofSessions';
|
||||
|
||||
const mockAlbum = immich.getProofingAlbum as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockNotify = notifySubmitted as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockLock = siteLockResponse as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const ALBUM = {
|
||||
id: ALBUM_ID,
|
||||
albumName: 'Wedding',
|
||||
assets: [
|
||||
{ id: 'a1', type: 'IMAGE', originalFileName: 'a1.jpg' },
|
||||
{ id: 'a2', type: 'IMAGE', originalFileName: 'a2.jpg' },
|
||||
{ id: 'a3', type: 'VIDEO', originalFileName: 'a3.mp4' },
|
||||
],
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-proof-routes-'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(dir);
|
||||
mockAlbum.mockResolvedValue(structuredClone(ALBUM));
|
||||
mockNotify.mockClear();
|
||||
mockLock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function session(overrides: Record<string, unknown> = {}) {
|
||||
return createSession(
|
||||
sessionInputSchema.parse({ clientName: 'Anna', albumId: ALBUM_ID, ...overrides }),
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = (token: string) => ({ params: Promise.resolve({ token }) });
|
||||
|
||||
function put(token: string, assets: unknown) {
|
||||
return PUT(
|
||||
new NextRequest(`https://folio.example/api/proof/${token}/selection`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ assets }),
|
||||
}),
|
||||
ctx(token),
|
||||
);
|
||||
}
|
||||
|
||||
const submit = (token: string) =>
|
||||
POST(
|
||||
new NextRequest(`https://folio.example/api/proof/${token}/submit`, { method: 'POST' }),
|
||||
ctx(token),
|
||||
);
|
||||
|
||||
const archive = (token: string, scope = 'selection') =>
|
||||
GET(
|
||||
new NextRequest(`https://folio.example/api/proof/${token}/archive?scope=${scope}`),
|
||||
ctx(token),
|
||||
);
|
||||
|
||||
describe('PUT selection', () => {
|
||||
it('saves the selection in album order', async () => {
|
||||
const s = await session();
|
||||
const res = await put(s.token, ['tok-a3', 'tok-a1']);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await getSessionById(s.id))?.selection).toEqual(['a1', 'a3']);
|
||||
// The album comes from the session, not from the request.
|
||||
expect(mockAlbum).toHaveBeenCalledWith(ALBUM_ID);
|
||||
});
|
||||
|
||||
it('refuses an asset from another album', async () => {
|
||||
const s = await session();
|
||||
expect((await put(s.token, ['tok-a1', 'tok-elsewhere'])).status).toBe(400);
|
||||
expect((await put(s.token, ['garbage'])).status).toBe(400);
|
||||
expect((await put(s.token, 'tok-a1')).status).toBe(400);
|
||||
expect((await getSessionById(s.id))?.selection).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown or expired link', async () => {
|
||||
expect((await put('x'.repeat(32), ['tok-a1'])).status).toBe(404);
|
||||
const expired = await session({ expiresOn: '2000-01-01' });
|
||||
expect((await put(expired.token, ['tok-a1'])).status).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 409 once submitted', async () => {
|
||||
const s = await session();
|
||||
await put(s.token, ['tok-a1']);
|
||||
await submit(s.token);
|
||||
expect((await put(s.token, ['tok-a2'])).status).toBe(409);
|
||||
});
|
||||
|
||||
it('keeps the site password in charge', async () => {
|
||||
const s = await session();
|
||||
mockLock.mockReturnValue(Response.json({ error: 'locked' }, { status: 401 }));
|
||||
expect((await put(s.token, ['tok-a1'])).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST submit', () => {
|
||||
it('refuses an empty selection', async () => {
|
||||
const s = await session();
|
||||
expect((await submit(s.token)).status).toBe(400);
|
||||
expect(mockNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the selection and notifies once', async () => {
|
||||
const s = await session();
|
||||
await put(s.token, ['tok-a2']);
|
||||
expect((await submit(s.token)).status).toBe(200);
|
||||
expect((await submit(s.token)).status).toBe(200);
|
||||
expect(mockNotify).toHaveBeenCalledTimes(1);
|
||||
expect((await getSessionById(s.id))?.submittedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET archive', () => {
|
||||
it('refuses when the link offers no downloads', async () => {
|
||||
const s = await session();
|
||||
await put(s.token, ['tok-a1']);
|
||||
expect((await archive(s.token)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('streams only the saved selection', async () => {
|
||||
const s = await session({ download: 'selection' });
|
||||
await put(s.token, ['tok-a2']);
|
||||
const res = await archive(s.token);
|
||||
expect(await res.json()).toEqual({ name: 'Wedding', ids: ['a2'] });
|
||||
});
|
||||
|
||||
it('allows the whole album only when the link says so', async () => {
|
||||
const selectionOnly = await session({ download: 'selection' });
|
||||
expect((await archive(selectionOnly.token, 'album')).status).toBe(404);
|
||||
|
||||
const whole = await session({ download: 'album' });
|
||||
const res = await archive(whole.token, 'album');
|
||||
expect((await res.json()).ids).toEqual(['a1', 'a2', 'a3']);
|
||||
});
|
||||
|
||||
it('stops at the download limit', async () => {
|
||||
const s = await session({ download: 'album', downloadLimit: 1 });
|
||||
expect((await archive(s.token, 'album')).status).toBe(200);
|
||||
const refused = await archive(s.token, 'album');
|
||||
expect(refused.status).toBe(403);
|
||||
expect(await refused.json()).toEqual({ reason: 'limitReached' });
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { immich, ImmichUnavailableError } from '@/lib/immich';
|
||||
import { isPublishedAsset } from '@/lib/publishedAssets';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
@@ -47,6 +49,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only what the site currently shows (GHSA-gfh4-6275-9gqv): a token stays
|
||||
// decodable forever, so an unpublished album or a revoked proofing link
|
||||
// would otherwise keep serving. Signed-in admins pass for previews.
|
||||
if (!(await isPublishedAsset(assetId)) && !(await isAdminAuthenticated())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not found' },
|
||||
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
// Forward Range header from browser (needed for <video> seeking)
|
||||
const rangeHeader = request.headers.get('range');
|
||||
|
||||
|
||||
152
app/api/webhook/__tests__/route.test.ts
Normal file
152
app/api/webhook/__tests__/route.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
/**
|
||||
* The webhook is reachable by anyone, and the body has to be read before its
|
||||
* HMAC can be checked. The read is therefore capped: a declared length over
|
||||
* the cap is refused without touching the stream, and a body that turns out
|
||||
* longer than declared (or declares nothing, as a chunked one does) is cut off
|
||||
* at the cap. The signature must be exactly the hex digest of the raw bytes.
|
||||
*/
|
||||
|
||||
const SECRET = 'test-webhook-secret';
|
||||
|
||||
const env = vi.hoisted(() => ({ WEBHOOK_SECRET: '' as string | undefined }));
|
||||
vi.mock('@/lib/env', () => ({ env }));
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { invalidateAlbum: vi.fn(), invalidateAll: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
|
||||
getClientIp: vi.fn(() => '127.0.0.1'),
|
||||
}));
|
||||
|
||||
import { POST } from '../route';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
const URL = 'http://localhost/api/webhook';
|
||||
const CAP = 64 * 1024;
|
||||
|
||||
function sign(body: string | Uint8Array): string {
|
||||
return createHmac('sha256', SECRET).update(body).digest('hex');
|
||||
}
|
||||
|
||||
function post(body: string, headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A request whose body is a lazily produced stream of `chunks` chunks of
|
||||
* `size` bytes each. `pulls()` reports how many chunks the route asked for.
|
||||
*/
|
||||
function streamed(chunks: number, size: number, headers: Record<string, string> = {}) {
|
||||
let pulled = 0;
|
||||
const chunk = new Uint8Array(size).fill(0x20); // spaces: valid JSON whitespace
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (pulled >= chunks) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
pulled++;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const request = new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers,
|
||||
duplex: 'half', // undici requires it for a stream body
|
||||
});
|
||||
return { request, pulls: () => pulled };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
env.WEBHOOK_SECRET = SECRET;
|
||||
});
|
||||
|
||||
describe('POST /api/webhook', () => {
|
||||
it('answers 501 when WEBHOOK_SECRET is unset, before looking at the size', async () => {
|
||||
env.WEBHOOK_SECRET = undefined;
|
||||
const { request } = streamed(1, 16, { 'content-length': String(CAP * 10) });
|
||||
const res = await POST(request);
|
||||
expect(res.status).toBe(501);
|
||||
expect(request.bodyUsed).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an oversized Content-Length with 413 without reading the body', async () => {
|
||||
const { request } = streamed(1, 16, {
|
||||
'content-length': String(CAP + 1),
|
||||
'x-immich-signature': 'a'.repeat(64),
|
||||
});
|
||||
const res = await POST(request);
|
||||
expect(res.status).toBe(413);
|
||||
expect(request.bodyUsed).toBe(false);
|
||||
});
|
||||
|
||||
it('cuts off a chunked body over the cap, even with a valid signature', async () => {
|
||||
// 100 chunks of 1 KiB = 100 KiB; the whole body is signed correctly, so
|
||||
// only the size cap can turn it away.
|
||||
const size = 1024;
|
||||
const chunks = 100;
|
||||
const full = ' '.repeat(size * chunks);
|
||||
const { request, pulls } = streamed(chunks, size, { 'x-immich-signature': sign(full) });
|
||||
expect(request.headers.get('content-length')).toBeNull();
|
||||
|
||||
const res = await POST(request);
|
||||
expect(res.status).toBe(413);
|
||||
expect(pulls()).toBeLessThan(chunks);
|
||||
expect(immich.invalidateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a correctly signed payload under the cap', async () => {
|
||||
const body = JSON.stringify({ event: 'album.updated', albumId: 'album-1' });
|
||||
const res = await POST(post(body, { 'x-immich-signature': sign(body) }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true, invalidated: 'album-1' });
|
||||
expect(immich.invalidateAlbum).toHaveBeenCalledWith('album-1');
|
||||
});
|
||||
|
||||
it('verifies the signature over the raw bytes, byte-order mark included', async () => {
|
||||
const json = JSON.stringify({ event: 'album.updated', albumId: 'album-1' });
|
||||
const raw = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(json)]);
|
||||
const res = await POST(
|
||||
new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
body: raw,
|
||||
headers: { 'x-immich-signature': sign(raw) },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('answers 401 for a wrong signature', async () => {
|
||||
const body = JSON.stringify({ event: 'album.updated', albumId: 'album-1' });
|
||||
const res = await POST(post(body, { 'x-immich-signature': sign('something else') }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(immich.invalidateAlbum).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 401 for a missing signature', async () => {
|
||||
const res = await POST(post('{"event":"x"}'));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['trailing non-hex characters', (sig: string) => `${sig}zz`],
|
||||
['a trailing odd nibble', (sig: string) => `${sig}0`],
|
||||
['an over-long digest', (sig: string) => sig.repeat(4)],
|
||||
['non-hex characters', () => 'z'.repeat(64)],
|
||||
])('answers 401 for a signature with %s', async (_label, mangle) => {
|
||||
const body = JSON.stringify({ event: 'album.updated', albumId: 'album-1' });
|
||||
const res = await POST(post(body, { 'x-immich-signature': mangle(sign(body)) }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(immich.invalidateAlbum).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,8 @@
|
||||
*
|
||||
* HMAC-SHA256(WEBHOOK_SECRET, raw request body)
|
||||
*
|
||||
* If WEBHOOK_SECRET is not set, the endpoint returns 501.
|
||||
* If WEBHOOK_SECRET is not set, the endpoint returns 501. Bodies over
|
||||
* MAX_BODY_BYTES are refused with 413 before the signature is checked.
|
||||
*
|
||||
* Supported Immich event types:
|
||||
* - album.updated → invalidates the specific album
|
||||
@@ -23,10 +24,23 @@ import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { env } from '@/lib/env';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
||||
import { readBytesCapped } from '@/lib/requestBody';
|
||||
|
||||
// Tight rate limit — webhooks should come from one trusted source only
|
||||
const WEBHOOK_RPM = 60;
|
||||
|
||||
/**
|
||||
* Largest body the route reads before the signature is checked. The payload
|
||||
* is a small JSON object (`{ "event": "album.updated", "albumId": "<uuid>" }`,
|
||||
* well under 1 KiB), so 64 KiB leaves ample headroom for extra fields while
|
||||
* keeping an unauthenticated sender from making the server buffer and hash
|
||||
* an arbitrarily large body.
|
||||
*/
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
/** An HMAC-SHA256 hex digest: exactly 64 hex characters. */
|
||||
const SIGNATURE_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
|
||||
/** Album-scoped Immich webhook events. */
|
||||
const ALBUM_EVENTS = new Set(['album.updated', 'album.assetAdded', 'album.assetRemoved']);
|
||||
|
||||
@@ -46,13 +60,24 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
|
||||
}
|
||||
|
||||
// ── Body size ──────────────────────────────────────
|
||||
// The declared length is refused before a byte is read; the capped reader
|
||||
// covers a missing or understated Content-Length and chunked bodies.
|
||||
const declared = Number(request.headers.get('content-length') ?? '');
|
||||
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: 'Payload too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
// ── Read body (needed for HMAC verification) ───────
|
||||
let rawBody: string;
|
||||
let rawBody: Buffer | null;
|
||||
try {
|
||||
rawBody = await request.text();
|
||||
rawBody = await readBytesCapped(request, MAX_BODY_BYTES);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to read request body' }, { status: 400 });
|
||||
}
|
||||
if (rawBody === null) {
|
||||
return NextResponse.json({ error: 'Payload too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
// ── Signature verification ─────────────────────────
|
||||
const signature = request.headers.get('x-immich-signature');
|
||||
@@ -63,13 +88,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const expected = createHmac('sha256', env.WEBHOOK_SECRET).update(rawBody).digest('hex');
|
||||
|
||||
let signatureValid = false;
|
||||
try {
|
||||
signatureValid = timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
|
||||
} catch {
|
||||
// Buffer lengths differ → invalid hex or wrong length
|
||||
signatureValid = false;
|
||||
}
|
||||
// Anything but a 64-character hex digest is refused before decoding, so a
|
||||
// malformed or over-long header gets the same answer as a wrong one.
|
||||
const signatureValid =
|
||||
SIGNATURE_PATTERN.test(signature) &&
|
||||
timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
|
||||
|
||||
if (!signatureValid) {
|
||||
console.warn(`[Webhook] ⚠️ Invalid signature from ${ip}`);
|
||||
@@ -79,7 +102,7 @@ export async function POST(request: NextRequest) {
|
||||
// ── Parse payload ──────────────────────────────────
|
||||
let payload: { event?: string; albumId?: string } = {};
|
||||
try {
|
||||
payload = JSON.parse(rawBody);
|
||||
payload = JSON.parse(new TextDecoder().decode(rawBody));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user