import { NextRequest, NextResponse } from "next/server"; import { getPublicApiBaseUrl } from "@/lib/env"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 300; function matchesRequestOrigin(request: NextRequest, origin: string) { const host = request.headers.get("host"); if (host === null) return origin === request.nextUrl.origin; // Next can normalize loopback URLs to localhost. Compare with the actual // HTTP authority, not that normalized hostname or an untrusted forwarded // host. Keep protocol and port strict; do not widen the allowed origins. if (!host || /[\\/@?#,\s]/.test(host)) return false; try { return origin === new URL(`${request.nextUrl.protocol}//${host}`).origin; } catch { return false; } } async function proxy(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { const { path } = await context.params; if (path.some((part) => !part || part === "." || part === ".." || /[\\/]/.test(part))) { return NextResponse.json({ error: "Invalid API path" }, { status: 400 }); } const pathname = `/${path.map(encodeURIComponent).join("/")}`; const origin = request.headers.get("origin"); if (!["GET", "HEAD"].includes(request.method) && origin && !matchesRequestOrigin(request, origin)) { return NextResponse.json({ error: "Invalid request origin" }, { status: 403 }); } const upstream = new URL(getPublicApiBaseUrl()); upstream.pathname = `${upstream.pathname.replace(/\/$/, "")}${pathname}`; upstream.search = request.nextUrl.search; const headers = new Headers(); // The student's timezone is a validated planning hint, not an identity header. for (const name of ["authorization", "content-type", "accept", "range", "if-range", "idempotency-key", "x-request-id", "x-student-timezone"]) { const value = request.headers.get(name); if (value) headers.set(name, value); } const cookie = request.cookies.get("docdoe_media_token"); if (process.env.NODE_ENV !== "production" && process.env.LOG_LEVEL === "debug") { console.debug("[FIX:student-calendar] Forwarding planning context", { timezoneProvided: headers.has("x-student-timezone"), }); } if (cookie && (pathname.startsWith("/generated/") || pathname === "/auth/logout")) { headers.set("cookie", `docdoe_media_token=${encodeURIComponent(cookie.value)}`); } try { const response = await fetch(upstream, { method: request.method, headers, body: ["GET", "HEAD"].includes(request.method) ? undefined : request.body, // Node fetch requires duplex when forwarding streaming upload bodies. ...({ duplex: "half" } as Record), signal: AbortSignal.any([request.signal, AbortSignal.timeout(290_000)]), redirect: "manual", cache: "no-store", }); const outgoing = new Headers({ "Cache-Control": "private, no-store", "Vary": "Authorization, Cookie" }); for (const name of ["content-type", "content-range", "accept-ranges", "content-disposition", "retry-after", "x-request-id"]) { const value = response.headers.get(name); if (value) outgoing.set(name, value); } const result = new NextResponse(request.method === "HEAD" ? null : response.body, { status: response.status, headers: outgoing }); // Only the authenticated profile endpoint can promote an existing bearer // session to a media cookie. Public API requests cannot mint one. let mediaToken = response.ok && request.method === "GET" && ["/users/me", "/auth/session"].includes(pathname) ? request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") : undefined; for (const value of response.headers.getSetCookie()) { const match = /^docdoe_media_token=([^;]*)/.exec(value); if (match) mediaToken = match[1]; } const clearSession = pathname === "/auth/logout" || (pathname === "/users/me" && request.method === "DELETE" && response.ok); if (mediaToken !== undefined || clearSession) { const token = clearSession ? "" : mediaToken ?? ""; result.cookies.set("docdoe_media_token", token, { httpOnly: true, secure: request.nextUrl.protocol === "https:", sameSite: "lax", path: "/api", maxAge: token ? 604800 : 0, }); } return result; } catch { console.error("[FIX:backend-proxy] Upstream request failed", { method: request.method }); return NextResponse.json({ error: { message: "The study service could not be reached. Please retry.", code: "UPSTREAM_UNAVAILABLE" } }, { status: 502 }); } } export { proxy as GET, proxy as HEAD, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };