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';
|
||||
|
||||
Reference in New Issue
Block a user