Files

186 lines
6.1 KiB
TypeScript
Raw Permalink Normal View History

import { NextResponse, type NextRequest } from "next/server"
import { getSessionCookie } from "better-auth/cookies"
const PROTECTED_PATHS = [
"/admin",
"/dashboard",
"/properties",
"/tenants",
"/rent",
"/maintenance",
"/leases",
"/expenses",
"/settings",
"/onboarding",
"/calendar",
"/inspections",
"/vendors",
"/reports",
"/activity",
"/ai",
"/ai-dashboard",
"/predictions",
"/recommendations",
"/impact",
"/follow-ups",
"/team",
]
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
// Origin of the Sentry ingest endpoint, derived from the public DSN so the
// CSP stays in sync with whatever project/region the DSN points at. Returns
// null when Sentry is not configured.
function sentryIngestOrigin(): string | null {
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
if (!dsn) return null
try {
return new URL(dsn).origin
} catch {
return null
}
}
// Origin serving the Umami analytics script (script.js) and receiving its event
// beacons (POST /api/send). Mirrors the component default so the CSP allows both
// loading the script AND sending events; stays in sync with NEXT_PUBLIC_UMAMI_SRC
// when overridden.
function umamiOrigin(): string {
const src = process.env.NEXT_PUBLIC_UMAMI_SRC || "https://fickanalytics.phluit.net/script.js"
try {
return new URL(src).origin
} catch {
return ""
}
}
// Build the Content-Security-Policy. `script-src` uses 'unsafe-inline' because
// Next.js 16's Turbopack build does NOT stamp a per-request nonce onto its
// inline hydration scripts (`self.__next_f.push(...)`). A nonce-based policy
// therefore blocks those inline scripts and the app never hydrates (blank page).
// `style-src` also keeps 'unsafe-inline' (Radix / Tailwind / framer-motion inject
// inline styles). NOTE: to restore the stricter nonce-based script policy, build
// with webpack (`next build --webpack`) so Next applies the nonce to its scripts.
function buildCsp(): string {
const isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin()
// Dev additionally needs 'unsafe-eval' (Turbopack HMR) plus a dev websocket
// (added to connect-src below).
const umami = umamiOrigin()
const scriptSrc = [
"script-src 'self' 'unsafe-inline'",
isDev ? "'unsafe-eval'" : "",
"https://challenges.cloudflare.com",
umami, // load the Umami analytics script
]
.filter(Boolean)
.join(" ")
const connectSrc = [
"connect-src 'self'",
isDev ? "ws: wss:" : "",
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
umami, // Umami event beacons (POST /api/send)
sentry ?? "",
]
.filter(Boolean)
.join(" ")
2026-07-01 13:56:34 -04:00
return [
"default-src 'self'",
"img-src 'self' data: blob: https:",
"style-src 'self' 'unsafe-inline'",
scriptSrc,
2026-07-01 13:56:34 -04:00
"font-src 'self' data:",
connectSrc,
// Sentry Session Replay spins up its compression worker from a blob: URL;
// without worker-src the browser falls back to script-src and blocks it.
"worker-src 'self' blob:",
2026-07-01 13:56:34 -04:00
"frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
2026-07-01 13:56:34 -04:00
].join("; ")
}
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
// an auth page we confirm the session is actually alive — otherwise a stale
// cookie loops forever: /dashboard → /login (server sees no session) →
// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS.
// "unknown" (auth service unreachable / rate-limited) renders the auth page
// without touching cookies, which is safe in both directions.
async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> {
try {
const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin
const res = await fetch(new URL("/api/auth/get-session", base), {
headers: { cookie: request.headers.get("cookie") ?? "" },
cache: "no-store",
})
if (!res.ok) return "unknown"
// Better Auth returns JSON `null` when the session is missing or revoked.
const session = await res.json()
return session ? "valid" : "invalid"
} catch {
return "unknown"
}
}
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Optimistic check based on the presence of the session cookie. Real
// enforcement happens in routes / server components via getSessionUser().
const sessionCookie = getSessionCookie(request)
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p))
if (isProtected && !sessionCookie) {
const url = request.nextUrl.clone()
url.pathname = "/login"
return NextResponse.redirect(url)
}
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
let dropStaleSessionCookie = false
if (isAuthPage && sessionCookie) {
const state = await sessionState(request)
if (state === "valid") {
const url = request.nextUrl.clone()
url.pathname = "/dashboard"
return NextResponse.redirect(url)
}
// Dead cookie (session revoked or expired): render the auth page and drop
// the cookie below so protected paths stop treating this visitor as
// signed in. On "unknown", render the page but keep the cookie.
dropStaleSessionCookie = state === "invalid"
}
const csp = buildCsp()
2026-07-01 13:56:34 -04:00
const response = NextResponse.next()
// Set the CSP on the outgoing response so the browser enforces it.
2026-07-01 13:56:34 -04:00
response.headers.set("Content-Security-Policy", csp)
if (dropStaleSessionCookie) {
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
for (const cookie of request.cookies.getAll()) {
if (!cookie.name.includes("better-auth.session_token")) continue
response.cookies.set(cookie.name, "", {
maxAge: 0,
path: "/",
httpOnly: true,
sameSite: "lax",
secure: cookie.name.startsWith("__Secure-"),
})
}
}
2026-07-01 13:56:34 -04:00
return response
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
}