import { NextRequest, NextResponse } from "next/server"; import { getToken } from "next-auth/jwt"; import { rateLimit } from "@/lib/rate-limit"; import { ADMIN_GATE_HEADER, ADMIN_PATHNAME_HEADER, adminGateToken, } from "@/lib/admin-gate"; export async function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname; // ── ADMIN GUARD ────────────────────────────────────────────────────────── if (pathname.startsWith("/admin")) { // Stamp the path so src/app/admin/layout.tsx can run its own session check // and still tell /admin/login apart, plus a secret-derived token proving // this proxy ran. set() overwrites any client-supplied value; if the proxy // is bypassed entirely neither header is valid and the layout fails closed. const withPath = new Headers(request.headers); withPath.set(ADMIN_PATHNAME_HEADER, pathname); withPath.set(ADMIN_GATE_HEADER, await adminGateToken()); const forward = { request: { headers: withPath } }; // Allow the login page and NextAuth API routes through without session check if ( pathname === "/admin/login" || pathname.startsWith("/api/auth") ) { return NextResponse.next(forward); } const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, }); if (!token) { const loginUrl = new URL("/admin/login", request.url); loginUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(loginUrl); } return NextResponse.next(forward); } // ── CLIENT TOKEN/SLUG GUARD ────────────────────────────────────────────── if (pathname.startsWith("/client/")) { const slugOrTokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/); if (!slugOrTokenMatch) { return NextResponse.rewrite(new URL("/not-found", request.url)); } // Client slugs carry far less entropy than the 21-char nanoid tokens // (see C-2 in .planning/SECURITY-SCAN.md), so this path must not be // brute-forceable at speed. Was previously applied only to /quote. const clientIp = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown"; if (!rateLimit(`client:${clientIp}`, 20, 60 * 1000)) { return NextResponse.json( { error: "Troppi accessi. Riprova tra un minuto." }, { status: 429 } ); } const slugOrToken = slugOrTokenMatch[1]; try { // Use localhost to avoid hairpin NAT issues in Docker. // request.url is the external hostname (via Traefik); inside the container the app is on localhost. const port = process.env.PORT ?? "3000"; const base = `http://localhost:${port}`; const internalHeaders = { "x-internal-secret": process.env.INTERNAL_SECRET ?? "", }; // Try slug first (D-06) — user-friendly slugs before token fallback let res = await fetch( `${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`, { headers: internalHeaders } ); // If slug not found, fall back to token validation (existing links continue to work) if (!res.ok) { res = await fetch( `${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`, { headers: internalHeaders } ); } if (!res.ok) { return NextResponse.rewrite(new URL("/not-found", request.url)); } return NextResponse.next(); } catch { return NextResponse.rewrite(new URL("/not-found", request.url)); } } // ── PUBLIC QUOTE ROUTES (rate limited) ─────────────────────────────────── if (pathname.match(/^\/quote\/[a-zA-Z0-9_-]{21}\/?$/)) { const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown"; const allowed = rateLimit(ip, 3, 60 * 1000); // 3 views per minute if (!allowed) { return NextResponse.json( { error: "Troppi accessi. Riprova tra un minuto." }, { status: 429 } ); } } return NextResponse.next(); } export const config = { matcher: ["/admin/:path*", "/client/:path*", "/quote/:path*"], };