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)); } /** * 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. */ 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) { const slug = decodeURIComponent(journal[1]); 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`)), ); } 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 //