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>
245 lines
9.9 KiB
TypeScript
245 lines
9.9 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { NextResponse } from 'next/server';
|
|
import type { NextRequest } from 'next/server';
|
|
import { getConfigOrNull } from '@/lib/config';
|
|
import { isValidSlug } from '@/lib/journal';
|
|
import { privacyAvailable } from '@/lib/privacy';
|
|
import { isSiteUnlocked } from '@/lib/auth';
|
|
import { isAdminPath } from '@/lib/admin/paths';
|
|
import { isInstallPath } from '@/lib/install';
|
|
import { cdnOrigin } from '@/lib/cdn';
|
|
|
|
/** Where a locked-out visitor is rewritten to. */
|
|
const GATE_PATH = '/gate';
|
|
|
|
/**
|
|
* Site-wide password gate.
|
|
*
|
|
* This has to happen here rather than in the root layout. A layout that swaps
|
|
* `children` for a password form still lets the page render — the RSC payload
|
|
* for the requested route is produced either way, and album names and asset
|
|
* tokens travel with it. Rewriting means the protected page is never rendered
|
|
* at all.
|
|
*
|
|
* Route handlers are outside the matcher and carry their own check; see
|
|
* `siteLockResponse` in `lib/auth.ts`.
|
|
*/
|
|
function siteGate(request: NextRequest): NextResponse | null {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// /admin owns its own password and is where the site password is set;
|
|
// /install is the wizard a fresh deployment needs before it can have a
|
|
// password at all. Locking either would be locking out the operator.
|
|
if (isAdminPath(pathname) || isInstallPath(pathname) || pathname === GATE_PATH) return null;
|
|
|
|
// A config that cannot be parsed makes getConfig() throw, and it throws in
|
|
// here — before app/layout.tsx gets to call getConfigOrNull() and render the
|
|
// setup screen instead. Unhandled, that turned one typo in gallery.yaml into
|
|
// a bare 500 on every route, with nothing naming the file (#516).
|
|
//
|
|
// Letting the request through is safe precisely because the config is
|
|
// unreadable: every page below reaches the same throw and renders the setup
|
|
// screen, so there is no album name and no asset token left to protect. The
|
|
// operator gets a page that explains itself, and /admin — exempt above — stays
|
|
// reachable to fix the file.
|
|
let unlocked: boolean;
|
|
try {
|
|
unlocked = isSiteUnlocked((name) => request.cookies.get(name)?.value);
|
|
} catch (error) {
|
|
console.error(
|
|
'[Folio] The site password could not be resolved — content/gallery.yaml or ' +
|
|
'content/settings.yaml cannot be read. Serving the setup screen:',
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (unlocked) return null;
|
|
|
|
// Rewrite, not redirect: the URL the visitor asked for stays in the address
|
|
// bar, so unlocking lands them where they were going.
|
|
return NextResponse.rewrite(new URL(GATE_PATH, request.url));
|
|
}
|
|
|
|
/**
|
|
* The first segments that belong to a fixed route under app/ rather than to
|
|
* the app/[...path] catch-all. A deep path below one of these is either served
|
|
* by that route or answered 404 by Next itself, so isKnownMissing() leaves it
|
|
* alone. lib/__tests__/proxy.test.ts holds this list to the app/ directory.
|
|
*/
|
|
export const TOP_LEVEL_ROUTES: ReadonlySet<string> = new Set([
|
|
'about',
|
|
'admin',
|
|
'api',
|
|
'contact',
|
|
'gate',
|
|
'impressum',
|
|
'install',
|
|
'journal',
|
|
'map',
|
|
'privacy',
|
|
'proof',
|
|
]);
|
|
|
|
/**
|
|
* Pages that are known not to exist before anything renders.
|
|
*
|
|
* app/loading.tsx makes every page stream, and a streamed response has sent
|
|
* its 200 before the page gets to call notFound(). Next then marks the page
|
|
* noindex, which keeps it out of search results, but the status stays 200.
|
|
* For the fixed routes whose existence depends only on settings.yaml or a file
|
|
* in content/journal/, the answer is known here, before streaming starts, so
|
|
* they can get a real 404. Album and subpage slugs need Immich and stay soft.
|
|
*
|
|
* The same holds for any path deeper than two segments that lands in the
|
|
* catch-all: app/[...path] only serves `/<slug>` and `/<subpage>/<album>`, and
|
|
* calls notFound() for everything longer without asking Immich.
|
|
*/
|
|
export function isKnownMissing(
|
|
pathname: string,
|
|
contentDir = path.join(process.cwd(), 'content'),
|
|
): boolean {
|
|
const config = getConfigOrNull();
|
|
if (!config) return false;
|
|
|
|
if (pathname === '/contact') return !config.contact.enabled;
|
|
if (pathname === '/impressum') return !config.legal.enabled;
|
|
if (pathname === '/privacy') return !privacyAvailable(config, contentDir);
|
|
if (pathname === '/map') return !config.map;
|
|
|
|
const journal = /^\/journal\/([^/]+)\/?$/.exec(pathname);
|
|
if (journal) {
|
|
let slug: string;
|
|
try {
|
|
slug = decodeURIComponent(journal[1]);
|
|
} catch {
|
|
// A malformed escape such as /journal/%E0 names no entry, and must not
|
|
// throw out of the proxy.
|
|
return true;
|
|
}
|
|
if (!isValidSlug(slug)) return true;
|
|
// A draft still has its file, so it stays a soft 404 for visitors and
|
|
// reachable for a signed-in admin, which only the page can tell apart.
|
|
return !['journal', 'essays'].some((dir) =>
|
|
fs.existsSync(path.join(contentDir, dir, `${slug}.md`)),
|
|
);
|
|
}
|
|
|
|
const segments = pathname.split('/').filter(Boolean);
|
|
if (segments.length > 2) {
|
|
const [first] = segments;
|
|
// Next's own paths (/_next/…, /__nextjs_…) and dotfiles such as
|
|
// /.well-known/… are not the catch-all's to answer.
|
|
if (first.startsWith('_') || first.startsWith('.')) return false;
|
|
return !TOP_LEVEL_ROUTES.has(first);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function proxy(request: NextRequest) {
|
|
/*
|
|
* Runs before the prefetch shortcut below, and must keep doing so: a
|
|
* prefetch asks for the same RSC payload as a normal navigation, so a gate
|
|
* that skipped them could be walked straight past with one request header.
|
|
*/
|
|
const gated = siteGate(request);
|
|
if (gated) return gated;
|
|
|
|
/*
|
|
* Prefetches used to be excluded by the matcher itself. They are filtered
|
|
* here instead, because the matcher is now also what makes the gate above
|
|
* run — and a matcher that skips prefetches would skip the gate with them.
|
|
* The effect on the CSP is unchanged: a prefetch gets no nonce and no
|
|
* policy header, exactly as before.
|
|
*/
|
|
if (
|
|
request.headers.get('next-router-prefetch') !== null ||
|
|
request.headers.get('purpose') === 'prefetch'
|
|
) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
|
|
|
|
const isDev = process.env.NODE_ENV === 'development';
|
|
|
|
// CDN mode (lib/cdn.ts) serves photos and videos from another origin. Media
|
|
// has no directive of its own otherwise and falls back to default-src.
|
|
const cdn = cdnOrigin();
|
|
|
|
// Define CSP directives
|
|
const cspDirectives = [
|
|
"default-src 'self'",
|
|
// No 'unsafe-inline' fallback: CSP3 browsers ignore it next to a nonce, but
|
|
// CSP2-only browsers ignore 'strict-dynamic' and would honour it — making
|
|
// the fallback strictly worse than having none. There are no inline
|
|
// <script> tags in the app; Next.js nonces the ones it injects itself.
|
|
// In development mode, Next.js / React dev tools require 'unsafe-eval' for Fast Refresh
|
|
// and stack trace reconstruction. Omitted in production.
|
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''}`,
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"font-src 'self'",
|
|
`img-src 'self' data: blob: https://tile.openstreetmap.org${cdn ? ` ${cdn}` : ''}`,
|
|
...(cdn ? [`media-src 'self' ${cdn}`] : []),
|
|
"connect-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join('; ');
|
|
|
|
const requestHeaders = new Headers(request.headers);
|
|
requestHeaders.set('x-nonce', nonce);
|
|
// Layouts cannot read the request URL; the root layout needs the pathname
|
|
// to keep /admin reachable while the app is in setup mode.
|
|
requestHeaders.set('x-pathname', request.nextUrl.pathname);
|
|
requestHeaders.set('Content-Security-Policy', cspDirectives);
|
|
|
|
// The page itself still renders and calls notFound(); the 404 only lets the
|
|
// status say so before streaming starts. See isKnownMissing().
|
|
//
|
|
// This is NextResponse.next() carrying a status, not a rewrite to the same
|
|
// URL. A rewrite destination whose origin differs from the one the server
|
|
// resolved the request against is proxied as an external URL, and
|
|
// request.nextUrl reports 127.0.0.1 and ::1 as "localhost". Behind
|
|
// `next start -H 127.0.0.1` every such rewrite went out to localhost, which
|
|
// resolved to ::1 where nothing listens: a 30 s hang, then a 500.
|
|
const response = NextResponse.next({
|
|
request: { headers: requestHeaders },
|
|
...(isKnownMissing(request.nextUrl.pathname) ? { status: 404 } : {}),
|
|
});
|
|
|
|
// Only the CSP is set here. Every other security header comes from
|
|
// next.config.ts, which also covers /api and static assets. Setting a header
|
|
// in both places emits it twice with conflicting values.
|
|
response.headers.set('Content-Security-Policy', cspDirectives);
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except for:
|
|
* - /api and everything under it (API routes — JSON/binary responses, no
|
|
* document CSP needed; the public ones enforce the site gate themselves)
|
|
* - /_next/static/… (static files)
|
|
* - /_next/image (image optimization files)
|
|
* - /favicon.ico, /sitemap.xml, /robots.txt (metadata files)
|
|
*
|
|
* Every alternative ends on a boundary, and the dots are escaped. The
|
|
* pattern in the Next.js docs does neither, so it also excludes anything
|
|
* that merely starts with one of these names — an album slugged
|
|
* "apia-samoa" would never reach proxy(), and with it neither the site
|
|
* gate nor the CSP. lib/__tests__/proxy.test.ts compiles this pattern the
|
|
* way Next does and holds it to a list of paths.
|
|
*
|
|
* /admin is NOT excluded: it is the highest-privilege surface in the app
|
|
* and previously ran with no enforced CSP at all.
|
|
*
|
|
* Prefetches are no longer excluded here — see the note in proxy().
|
|
*/
|
|
'/((?!api(?:/|$)|_next/static/|_next/image(?:/|$)|favicon\\.ico$|sitemap\\.xml$|robots\\.txt$).*)',
|
|
],
|
|
};
|