import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; import { isMaintenanceModeEnabled } from "@/lib/maintenance"; const MAINTENANCE_EXEMPT_PATHS = new Set(["/impressum", "/datenschutz", "/nutzungsbedingungen", "/ueber-uns"]); const MAINTENANCE_HTML = ` Wartungsarbeiten · PawFeed

Wir sind gleich zurück

PawFeed führt gerade kurz Wartungsarbeiten durch. Bitte versuch es in wenigen Minuten erneut.

`; const isPublicRoute = createRouteMatcher([ "/", "/sign-up(.*)", "/log-in(.*)", "/forgot-password(.*)", "/reset-password(.*)", "/verify-email(.*)", "/health-card/(.*)", "/api/gdpr-export/(.*)", "/opengraph-image", "/impressum", "/datenschutz", "/nutzungsbedingungen", "/ueber-uns", "/join(.*)", "/api/webhooks/(.*)", "/api/auth/(.*)", "/api/cron/(.*)", "/api/csp-report", "/api/health", // tRPC handles its own auth per-procedure (protectedProcedure vs. // publicProcedure) and returns a proper JSON UNAUTHORIZED error — letting // Clerk's page-level auth.protect() intercept it first produced a 307 HTML // redirect to the sign-in page instead, which a JSON API client can't // parse, and incorrectly blocked publicProcedure calls (e.g. // explore.listPets) for logged-out visitors entirely. "/api/trpc/(.*)", ]); function getSupabaseHostname(): string { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; if (url) { try { return new URL(url).hostname; } catch { /* fall through */ } } return "*.supabase.co"; } function getGlitchtipHostname(): string { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; if (dsn) { try { return new URL(dsn).hostname; } catch { /* fall through */ } } return "glitchtip.pawfeed.org"; } export default clerkMiddleware( async (auth, request) => { const { pathname } = request.nextUrl; // Invite gate: block /sign-up unless the visitor has a valid invite cookie. // Set INVITE_REQUIRED=false in env to disable for the live/post-beta launch. if (pathname.startsWith("/sign-up")) { const inviteRequired = process.env.INVITE_REQUIRED !== "false"; if (inviteRequired && !request.cookies.has("pf_invite")) { return NextResponse.redirect(new URL("/join", request.url)); } } // Admin panel: validate secret path segment before anything else. // Returns 404 (not 403) — the panel's existence must not be revealed. if (pathname.startsWith("/p/")) { const segments = pathname.split("/"); const providedSecret = segments[2] ?? ""; const adminSecret = process.env.ADMIN_SECRET ?? ""; if (!adminSecret || !timingSafeStringEqual(providedSecret, adminSecret)) { return new NextResponse(null, { status: 404 }); } } // Maintenance mode: admin-toggled (admin.setMaintenanceMode), blocks all // page/app traffic except the admin panel itself (so it can be turned // back off), API routes (webhooks/cron/csp-report must keep working), // the fixed-language legal pages (Impressumspflicht), and sign-in/Clerk's // own routes — an admin whose session expired must still be able to log // back in and reach /p/* to disable maintenance mode. Fails open on a // Redis error — an outage there must never take the whole site down. if ( !pathname.startsWith("/p/") && !pathname.startsWith("/api/") && !pathname.startsWith("/log-in") && !pathname.startsWith("/sign-up") && !pathname.startsWith("/__clerk/") && !MAINTENANCE_EXEMPT_PATHS.has(pathname) ) { const maintenanceOn = await isMaintenanceModeEnabled().catch(() => false); if (maintenanceOn) { return new NextResponse(MAINTENANCE_HTML, { status: 503, headers: { "content-type": "text/html; charset=utf-8", "retry-after": "3600" }, }); } } if (!isPublicRoute(request)) { await auth.protect(); } }, { // Enforced as of 2026-08-22 (Mozilla Observatory docked 25 points for // Report-Only-only). The report-only period (since 2026-08-12/13) came // back clean: GlitchTip shows zero CSP violations since 2026-08-16 // (verified via issue_events_issueaggregate on the self-hosted instance) // except 6 script-src "eval" hits on 2026-08-15 from /pets/[petId] and // /onboarding/pet — both routes use react-easy-crop for avatar cropping, // the likely source, and it hasn't recurred since. Clerk's production // script-src default omits 'unsafe-eval' (see // node_modules/@clerk/nextjs/.../content-security-policy.js), so an // enforced policy would hard-block that call if it ever recurs. Adding // 'unsafe-eval' back here defensively avoids repeating the earlier // incident where an enforced CSP blanked the whole app (ClerkProvider // crashed uncaught on a blocked required resource) — it costs nothing // extra in Mozilla's scoring since 'unsafe-inline'/https:/http: are // already unconditionally in Clerk's default script-src. Revisit once // the eval source is confirmed fixed and remove this line. contentSecurityPolicy: { reportOnly: false, reportTo: "/api/csp-report", directives: { "script-src": ["unsafe-eval"], "img-src": [`https://${getSupabaseHostname()}`, "https://image.mux.com", "blob:"], "connect-src": [ `https://${getSupabaseHostname()}`, `https://${getGlitchtipHostname()}`, "https://stream.mux.com", "https://image.mux.com", "https://litix.io", "https://*.litix.io", "https://*.fastly.mux.com", "https://storage.googleapis.com", ], "media-src": [ "self", "blob:", "https://stream.mux.com", "https://*.edgemv.mux.com", "https://image.mux.com", ], "font-src": ["self", "data:"], "frame-ancestors": ["none"], "object-src": ["none"], "base-uri": ["self"], "report-uri": ["/api/csp-report"], }, }, } ); export const config = { matcher: [ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|txt|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", "/__clerk/(.*)", ], };