release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -38,9 +38,30 @@ const config = {
albumPasswords: {},
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
contact: { enabled: true, retentionDays: 90 },
legal: { enabled: true },
privacy: { enabled: true },
siteUrl: null,
// Past the gate, /api/analytics/track would write content/analytics.json in
// the checkout; turned off, it answers without writing. The gate runs first,
// so the locked-site rows still exercise it.
analytics: false,
};
/*
* The contact route stores what it accepts under content/messages/ and pushes
* to contact.notifyUrl. Both are replaced here, so an accepted submission
* touches neither the checkout's content/ directory nor the network;
* validateContact stays real, honeypot and fill-time check included.
*/
const contactStore = vi.hoisted(() => ({
saveMessage: vi.fn(async (fields: Record<string, string>) => ({ id: 'x', ...fields })),
notifyNewMessage: vi.fn(async () => {}),
}));
vi.mock('@/lib/contact', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/contact')>()),
...contactStore,
}));
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
@@ -99,6 +120,15 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
{ params: Promise.resolve({ album: 'tok', id: 'tok' }) } as never,
),
},
{
// The lightbox zoom's full-resolution file (#467).
name: 'GET /api/zoom/[album]/[id]',
call: async () =>
(await import('../zoom/[album]/[id]/route')).GET(
request('/api/zoom/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',
@@ -128,11 +158,15 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
),
},
{
// Stores whatever a visitor sends; a locked site accepts nothing from strangers.
name: 'POST /api/contact',
// Writes visitor-chosen paths into content/analytics.json. It used to be
// OPEN "to count the gate", but the layout mounts no tracker on the gate.
name: 'POST /api/analytics/track',
call: async () =>
(await import('../contact/route')).POST(
new NextRequest('http://localhost/api/contact', { method: 'POST', body: '{}' }) as never,
(await import('../analytics/track/route')).POST(
new NextRequest('http://localhost/api/analytics/track', {
method: 'POST',
body: '{"path":"/"}',
}) as never,
),
},
];
@@ -152,7 +186,8 @@ const OPEN: Record<string, string> = {
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',
contact:
'the Impressum links the form as its second contact channel, and /contact is served to a locked site',
};
/** `GET /api/download/[album]/[id]` → `download/[album]/[id]`. */
@@ -253,6 +288,58 @@ describe('routes that must stay open', () => {
// The password gate is set in the theme's fonts; locking them would render
// the gate itself in fallback fonts. Three public font names give nothing away.
describe('POST /api/contact on a locked site', () => {
/** Own rate-limit bucket per case: the route allows three a minute per IP. */
const submit = (ip: string, fields: Record<string, unknown>) =>
import('../contact/route').then(({ POST }) =>
POST(
new NextRequest('http://localhost/api/contact', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-real-ip': ip },
body: JSON.stringify(fields),
}) as never,
),
);
const valid = () => ({
name: 'Erika',
email: 'erika@example.com',
message: 'Wer betreibt diese Seite?',
website: '',
startedAt: Date.now() - 10_000,
});
beforeEach(() => {
config.sitePassword = 'letmein';
contactStore.saveMessage.mockClear();
});
// The Impressum links the form as its second contact channel, and the
// proxy serves /contact to a locked site; the endpoint has to take the post.
it('accepts a valid message without a session', async () => {
const res = await submit('10.0.0.1', valid());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(contactStore.saveMessage).toHaveBeenCalledTimes(1);
});
it('still drops a filled honeypot and a form sent too fast, unsaved', async () => {
expect((await submit('10.0.0.2', { ...valid(), website: 'spam.example' })).status).toBe(200);
expect((await submit('10.0.0.2', { ...valid(), startedAt: Date.now() })).status).toBe(200);
expect(contactStore.saveMessage).not.toHaveBeenCalled();
});
it('still refuses an invalid message', async () => {
const res = await submit('10.0.0.3', { ...valid(), email: 'not-an-address' });
expect(res.status).toBe(400);
expect(contactStore.saveMessage).not.toHaveBeenCalled();
});
it('still rate-limits at three a minute', async () => {
for (let i = 0; i < 3; i++) expect((await submit('10.0.0.4', valid())).status).toBe(200);
expect((await submit('10.0.0.4', valid())).status).toBe(429);
});
});
it('GET /api/fonts/css answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (
@@ -271,3 +358,49 @@ describe('routes that must stay open', () => {
expect(res.status).not.toBe(401);
});
});
/*
* The pages, not the route handlers: proxy.ts gates those. Driven through the
* real lib/auth here, with the password set, rather than through a mocked
* isSiteUnlocked() as in lib/__tests__/proxy.test.ts.
*/
describe('legal and contact pages on a locked site', () => {
const page = async (path: string, cookie?: string) =>
(await import('@/proxy')).proxy(
new NextRequest(`http://localhost${path}`, cookie ? { headers: { cookie } } : {}),
);
const rewrittenTo = (res: Response) => res.headers.get('x-middleware-rewrite');
beforeEach(() => {
config.sitePassword = 'letmein';
config.legal.enabled = true;
config.privacy.enabled = true;
config.contact.enabled = true;
});
// The gate page is public and links both. A German Impressum has to be
// reachable directly, and neither page carries an album name or asset token.
it.each(['/impressum', '/privacy', '/contact'])(
'%s is served without the password',
async (path) => {
expect(rewrittenTo(await page(path))).toBeNull();
},
);
it('every other page is still rewritten to the gate', async () => {
for (const path of ['/', '/japan', '/journal', '/about', '/contact/x', '/impressum/x']) {
expect(rewrittenTo(await page(path)), path).toContain('/gate');
}
});
it('a switched-off legal or contact page is still a 404, not the gate', async () => {
config.legal.enabled = false;
config.privacy.enabled = false;
config.contact.enabled = false;
for (const path of ['/impressum', '/privacy', '/contact']) {
const res = await page(path);
expect(rewrittenTo(res), path).toBeNull();
expect(res.status, path).toBe(404);
}
});
});