release: v0.19.0

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

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

---------

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

View File

@@ -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';