Files
immich-folio/proxy.ts
2026-10-02 18:14:23 +02:00

263 lines
11 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';
/**
* Pages a locked site still serves: the legal notice, the privacy policy and
* the contact form. The gate itself is public, and a German Impressum has to
* be reachable directly, not after a password, together with its second
* contact channel, which is often the contact form it links. None of the three
* carries an album name or an asset token, and the root layout drops the
* header and footer for a visitor who has not unlocked the site. A page that
* is switched off still answers 404 through isKnownMissing() below, so a
* disabled contact form is a 404 here, not the gate.
*/
export const UNGATED_PAGES: ReadonlySet<string> = new Set(['/impressum', '/privacy', '/contact']);
/**
* 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;
if (UNGATED_PAGES.has(pathname)) 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.
*
* A page inside a loading.tsx boundary streams, 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 get a real 404 whatever boundaries sit above them.
*
* Album and subpage slugs need Immich and are not decided here. The catch-all
* page decides them itself before it streams (app/[...path]/routeExists.ts),
* which is why app/ has no loading.tsx of its own: the home page's skeleton
* lives in the app/(home) route group.
*
* 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$).*)',
],
};