130 lines
5.6 KiB
TypeScript
130 lines
5.6 KiB
TypeScript
import NextAuth from "next-auth";
|
|
import { NextResponse } from "next/server";
|
|
import { authConfig } from "@/auth.config";
|
|
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
|
|
|
// /home, /appointments and /settings are intentionally NOT hard-gated here
|
|
// as of Phase 4 — Home must work for guests (public business browsing +
|
|
// booking), and /appointments and /settings each show their own friendly
|
|
// in-context sign-in prompt for a guest rather than a silent redirect with
|
|
// no explanation. Only /staff and /owner remain hard-gated: those are
|
|
// permission-based tooling with no meaningful "guest" experience at all.
|
|
const PROTECTED_PREFIXES = ["/staff", "/owner"];
|
|
|
|
// Reachable before installation completes: the installer itself, and the
|
|
// small set of static/manifest assets a browser requests independently of
|
|
// the page it's rendering (favicon, PWA manifest/icons, the offline
|
|
// fallback, the service worker script). Everything else redirects to
|
|
// /install until ApplicationInstallation flips to INSTALLED — see
|
|
// src/lib/installation/installation-state.ts. `/api/*` never reaches this
|
|
// file at all (excluded by `config.matcher` below); API routes that need
|
|
// business data guard themselves (see /api/health and
|
|
// /api/cron/process-notifications).
|
|
const INSTALL_EXEMPT_PREFIXES = [
|
|
"/install",
|
|
"/offline",
|
|
"/manifest.webmanifest",
|
|
"/icon.png",
|
|
"/apple-icon.png",
|
|
"/icons/",
|
|
"/sw.js",
|
|
];
|
|
|
|
// Lightweight instance built from the DB-free config — see auth.config.ts.
|
|
// Next.js 16 renamed this file convention from `middleware` to `proxy`
|
|
// (https://nextjs.org/docs/messages/middleware-to-proxy); it now always
|
|
// runs on the Node.js runtime rather than Edge, but this stays split from
|
|
// the full `@/auth` regardless, so checking "is there a session" doesn't
|
|
// spin up a Postgres connection pool on every request.
|
|
const { auth } = NextAuth(authConfig);
|
|
|
|
/**
|
|
* Nonce-gated 'strict-dynamic' script-src (Next's own documented pattern)
|
|
* was tried here first and reverted: verified live against a production
|
|
* build, it broke every page — Next's own inline RSC-hydration payload
|
|
* scripts (the `self.__next_f.push(...)` chunks each page ships to
|
|
* stream in its data) never actually receive the nonce this generates,
|
|
* so 'strict-dynamic' refuses to run them and the app never hydrates
|
|
* (confirmed via BAILOUT_TO_CLIENT_SIDE_RENDERING markers with
|
|
* `"nonce":"$undefined"` in the served HTML). `script-src 'self'
|
|
* 'unsafe-inline'` is the reliable fallback: Next's own *code* all loads
|
|
* via external `<script src="/_next/static/...">` tags, which `'self'`
|
|
* already covers — 'unsafe-inline' is only there for those small inline
|
|
* data-streaming scripts. `style-src` needs 'unsafe-inline' for the same
|
|
* reason as ever: this app uses plain React `style={{...}}` props
|
|
* (progress bars, dynamic widths) throughout, and CSP has no nonce
|
|
* mechanism for style *attributes* (only for <style> elements).
|
|
*/
|
|
function buildCsp(): string {
|
|
// Fast Refresh/React DevTools' dev-mode debugging needs eval() — React
|
|
// itself confirms it "will never use eval() in production mode", so
|
|
// this only loosens the policy for local development, never a real
|
|
// deployment.
|
|
const scriptSrc = process.env.NODE_ENV === "production" ? "'self' 'unsafe-inline'" : "'self' 'unsafe-inline' 'unsafe-eval'";
|
|
return [
|
|
"default-src 'self'",
|
|
`script-src ${scriptSrc}`,
|
|
"style-src 'self' 'unsafe-inline'",
|
|
// 'https:' (not just 'self' data:) — business logos/cover images are
|
|
// arbitrary externally-hosted URLs an owner pastes into Business
|
|
// Settings or the installer (see src/lib/business/branding.ts); there
|
|
// is no fixed set of allowed hosts to enumerate. Scoped to https: only
|
|
// (never http:), so this doesn't reopen the door to insecure image
|
|
// loads generally.
|
|
"img-src 'self' data: https:",
|
|
"font-src 'self'",
|
|
"connect-src 'self'",
|
|
"worker-src 'self'",
|
|
"manifest-src 'self'",
|
|
"object-src 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
"frame-ancestors 'none'",
|
|
].join("; ");
|
|
}
|
|
|
|
export default auth(async (req) => {
|
|
const { pathname } = req.nextUrl;
|
|
const csp = buildCsp();
|
|
|
|
const isInstallExempt = INSTALL_EXEMPT_PREFIXES.some(
|
|
(prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
|
);
|
|
const installed = await isInstalledForRequest();
|
|
|
|
if (!installed && !isInstallExempt) {
|
|
const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin));
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
return response;
|
|
}
|
|
if (installed && pathname === "/install") {
|
|
// The wizard itself also refuses to render/submit once installed (see
|
|
// src/app/install/page.tsx and the installer Server Action) — this
|
|
// redirect is a convenience for direct navigation, not the actual
|
|
// enforcement boundary.
|
|
const response = NextResponse.redirect(new URL("/", req.nextUrl.origin));
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
return response;
|
|
}
|
|
|
|
const isProtected = PROTECTED_PREFIXES.some(
|
|
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
|
);
|
|
|
|
if (isProtected && !req.auth) {
|
|
const signInUrl = new URL("/sign-in", req.nextUrl.origin);
|
|
signInUrl.searchParams.set("callbackUrl", pathname);
|
|
const response = NextResponse.redirect(signInUrl);
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
return response;
|
|
}
|
|
|
|
const response = NextResponse.next();
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
return response;
|
|
});
|
|
|
|
export const config = {
|
|
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
|
};
|