import { NextResponse, type NextRequest } from "next/server"; import { consume } from "@/lib/rate-limit"; const PUBLIC_PREFIXES = ["/api/auth/", "/s/", "/icon-", "/favicon"]; const PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]); const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"]; // Token-prefix length used as part of the rate-limit bucket key. const RL_PREFIX_LEN = 8; export function middleware(request: NextRequest) { const start = performance.now(); const response = route(request); logRequest(request, response.status, Math.round(performance.now() - start)); return response; } function withPathname(response: NextResponse, pathname: string): NextResponse { response.headers.set("x-pathname", pathname); return response; } function route(request: NextRequest): NextResponse { const { pathname } = request.nextUrl; // Rate-limit the public share-link viewer (/s/). // The rate limiter runs in the Edge runtime and has its own module-level bucket // Map (separate from the Node.js runtime used by page components). All requests // to /s/ are counted here regardless of whether the token resolves; page // components additionally track per-failure counts via recordFailure() for // accurate auditing. Switch to Redis for cross-runtime / multi-replica accuracy. if (pathname.startsWith("/s/") && pathname.length > 3) { const token = pathname.slice(3); // strip "/s/" const ip = clientIp(request); const key = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`; if (!consume(key)) { return new NextResponse("Too Many Requests", { status: 429, headers: { "Retry-After": "60", "Content-Type": "text/plain" }, }); } } if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) { return withPathname(NextResponse.next(), pathname); } if (hasSessionCookie(request)) return withPathname(NextResponse.next(), pathname); const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("callbackUrl", request.url); return NextResponse.redirect(loginUrl); } function clientIp(request: NextRequest): string { return ( request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "0.0.0.0" ); } function hasSessionCookie(request: NextRequest) { return request.cookies .getAll() .some((cookie) => SESSION_COOKIE_NAMES.some( (name) => cookie.name === name || cookie.name.startsWith(`${name}.`), ), ); } // Structured request log — output as JSON so it's machine-readable in production. // pino is not available in the Edge runtime; console.log goes to server stdout. // userId is omitted: decoding the session token requires a DB lookup unavailable here. function logRequest(request: NextRequest, status: number, ms: number) { const authenticated = hasSessionCookie(request); const entry = { level: "info", time: Date.now(), method: request.method, path: request.nextUrl.pathname, status, ms, authenticated, }; console.log(JSON.stringify(entry)); } export const config = { matcher: [ // Skip Next.js internals and static files "/((?!_next/static|_next/image|favicon.ico).*)", ], };