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 = 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 = 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 `/` and `//`, 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 //