File size: 4,691 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66680b1
 
65d68f5
 
 
 
66680b1
 
 
 
 
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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<string, string>),
      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 };