Files
petfeed/src/proxy.ts
T
admin 167b9e1a80 feat(seo): add llms.txt, fix middleware blocking .txt static files
Adds public/llms.txt (llms.txt spec: H1 + summary + linked sections)
describing the product for AI agents/crawlers -- usage-focused only, no
internal vendor/infra details.

Also fixes proxy.ts's middleware matcher, which excludes common static
file extensions from Clerk's auth gate but was missing .txt -- every
request to /llms.txt (and any future .txt file, e.g. robots.txt) was
being redirected to the Clerk sign-in page instead of served. No app
route under src/app uses a .txt path, so this only affects genuinely
static files in public/.

SpeedUp-Plan (Gitea #28) -- last remaining Lighthouse finding.
2026-08-20 20:43:25 +02:00

168 lines
6.0 KiB
TypeScript

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 = `<!doctype html>
<html lang="de"><head><meta charset="utf-8" />
<title>Wartungsarbeiten · PawFeed</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#0a0a0a;color:#fafafa;text-align:center;padding:1.5rem}</style>
</head><body><div><h1>Wir sind gleich zurück</h1><p>PawFeed führt gerade kurz Wartungsarbeiten durch. Bitte versuch es in wenigen Minuten erneut.</p></div></body></html>`;
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();
}
},
{
// Report-Only for now — an earlier enforced attempt blanked the whole
// app because ClerkProvider crashes uncaught when a required resource
// gets blocked mid-init. Clerk's own contentSecurityPolicy option (not
// the hand-rolled next.config.ts draft this replaces) already ships the
// 'unsafe-inline'/https: script-src Next.js needs to hydrate, which is
// what the hand-rolled version was missing. Move to enforced only after
// a clean report-only period — see /api/csp-report for the violations.
contentSecurityPolicy: {
reportOnly: true,
reportTo: "/api/csp-report",
directives: {
"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/(.*)",
],
};