You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

386 lines
10 KiB

import type { NextRequest } from "next/server";
import { isLocale } from "@/translations/config";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const PROXY_PATH_PARAM = "__proxyPath";
const REQUEST_HEADERS_TO_FORWARD = [
"accept",
"accept-language",
"authorization",
"cookie",
"token",
"content-type",
"x-csrf-token",
"x-csrftoken",
"x-requested-with",
"x-xsrf-token",
];
const RESPONSE_HEADERS_TO_DROP = [
"connection",
"content-encoding",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
const MAX_LOG_BODY_LENGTH = 10_000;
const shouldLogProxy = process.env.LOG_API_PROXY === "true";
const shouldLogProxyTiming =
process.env.LOG_API_PROXY_TIMING === "true" || shouldLogProxy;
class ProxyError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
function getApiBaseUrl() {
const apiBaseUrl =
process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL;
if (!apiBaseUrl) {
throw new ProxyError(
"API_BASE_URL or NEXT_PUBLIC_API_BASE_URL is required",
500,
);
}
try {
return new URL(apiBaseUrl);
} catch {
throw new ProxyError("API base URL is invalid", 500);
}
}
function getProxyPath(request: NextRequest) {
const proxyPath = request.nextUrl.searchParams.get(PROXY_PATH_PARAM);
if (!proxyPath) {
throw new ProxyError("Proxy path is required", 400);
}
if (/^[a-z][a-z\d+\-.]*:/i.test(proxyPath) || proxyPath.startsWith("//")) {
throw new ProxyError("Proxy path must be relative", 400);
}
return proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`;
}
function getTargetUrl(request: NextRequest) {
const targetUrl = getApiBaseUrl();
const proxyUrl = new URL(getProxyPath(request), targetUrl.origin);
const basePath = targetUrl.pathname.replace(/\/$/, "");
targetUrl.pathname = `${basePath}${proxyUrl.pathname}`;
const searchParams = new URLSearchParams(proxyUrl.search);
request.nextUrl.searchParams.forEach((value, key) => {
if (key !== PROXY_PATH_PARAM) {
searchParams.append(key, value);
}
});
targetUrl.search = searchParams.toString();
return targetUrl;
}
function getCookieValue(cookieHeader: string, name: string) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(?:^|;\\s*)${escapedName}=([^;]*)`);
const match = cookieHeader.match(pattern);
return match ? decodeURIComponent(match[1]) : null;
}
function getRequestHeaders(request: NextRequest, targetUrl: URL) {
const headers = new Headers();
const authKey = process.env.NEXT_PUBLIC_AUTH_KEY;
const cookieHeader = request.headers.get("cookie") ?? "";
for (const header of REQUEST_HEADERS_TO_FORWARD) {
const value = request.headers.get(header);
if (value) {
if (header === "token") {
// Convert token header to Authorization
headers.set("authorization", `Token ${value}`);
} else {
headers.set(header, value);
}
}
}
// Prefer a cookie-based token when the browser session is anonymous.
if (!headers.has("authorization")) {
const cookieToken =
getCookieValue(cookieHeader, "HABIB_TOKEN") ??
getCookieValue(cookieHeader, "habib_token") ??
getCookieValue(cookieHeader, "token") ??
getCookieValue(cookieHeader, "auth_token");
if (
cookieToken &&
cookieToken !== "NO_TOKEN" &&
cookieToken.trim() !== ""
) {
headers.set("authorization", `Token ${cookieToken}`);
}
}
// Override with authKey if set and no authorization header exists.
if (authKey && !headers.has("authorization")) {
headers.set("authorization", `Token ${authKey}`);
}
// Dynamically set language headers
const requestedLanguages = [
request.headers.get("x-user-language"),
getCookieValue(cookieHeader, "HABIB_LANGUAGE"),
getCookieValue(cookieHeader, "habib_language"),
request.headers.get("accept-language")?.split(",")[0]?.split("-")[0],
];
const lang =
requestedLanguages.find(
(candidate): candidate is string =>
candidate !== null && isLocale(candidate),
) ?? "en";
headers.set("accept-encoding", "gzip, br");
headers.set("accept-language", lang);
headers.set("x-user-language", lang);
headers.set("http_x_user_language", lang);
headers.set("origin", targetUrl.origin);
const clientUserAgent = request.headers.get("user-agent");
headers.set("user-agent", clientUserAgent || "dart:io");
// Forward client IP headers so backend can determine actual user location
const clientIp =
request.headers.get("cf-connecting-ip") ||
request.headers.get("x-real-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
if (clientIp) {
headers.set("x-forwarded-for", clientIp);
headers.set("x-real-ip", clientIp);
headers.set("x-client-ip", clientIp);
}
return headers;
}
function getResponseHeaders(upstreamHeaders: Headers) {
const headers = new Headers(upstreamHeaders);
for (const header of RESPONSE_HEADERS_TO_DROP) {
headers.delete(header);
}
const setCookieHeaders =
(
upstreamHeaders as Headers & { getSetCookie?: () => string[] }
).getSetCookie?.() ?? [];
if (setCookieHeaders.length > 0) {
headers.delete("set-cookie");
for (const cookie of setCookieHeaders) {
headers.append("set-cookie", cookie);
}
}
return headers;
}
function getBodyLogValue(body: ArrayBuffer | undefined, contentType?: string) {
if (!body || body.byteLength === 0) {
return null;
}
if (contentType && !isTextContentType(contentType)) {
return `[${body.byteLength} bytes; ${contentType}]`;
}
const text = new TextDecoder().decode(body);
if (text.length <= MAX_LOG_BODY_LENGTH) {
return parseJsonForLog(text, text);
}
return `${text.slice(0, MAX_LOG_BODY_LENGTH)}... [truncated ${text.length - MAX_LOG_BODY_LENGTH} chars]`;
}
function isTextContentType(contentType: string) {
return (
contentType.includes("application/json") ||
contentType.includes("application/problem+json") ||
contentType.startsWith("text/") ||
contentType.includes("+json") ||
contentType.includes("+xml")
);
}
function parseJsonForLog(text: string, fallback: string) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
function headersToObject(headers: Headers) {
return Object.fromEntries(headers.entries());
}
function logProxyRequest(
request: NextRequest,
targetUrl: URL,
requestHeaders: Headers,
requestBody: ArrayBuffer | undefined,
) {
if (!shouldLogProxy) {
return;
}
writeProxyLog("request", {
incomingRequest: {
method: request.method,
url: request.url,
nextUrl: request.nextUrl.toString(),
headers: headersToObject(request.headers),
body: getBodyLogValue(
requestBody,
request.headers.get("content-type") ?? undefined,
),
},
upstreamRequest: {
method: request.method,
url: targetUrl.toString(),
headers: headersToObject(requestHeaders),
body: getBodyLogValue(
requestBody,
requestHeaders.get("content-type") ?? undefined,
),
},
payload: getBodyLogValue(
requestBody,
request.headers.get("content-type") ?? undefined,
),
});
}
function logProxyResponse(
upstreamResponse: Response,
metrics: Record<string, number>,
) {
if (!shouldLogProxyTiming) {
return;
}
writeProxyLog("response", {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: headersToObject(upstreamResponse.headers),
metrics,
});
}
function writeProxyLog(label: string, value: unknown) {
console.log(`[api-proxy] ${label} ${JSON.stringify(value)}`);
}
async function proxyRequest(request: NextRequest) {
try {
const proxyStartedAt = performance.now();
const targetUrl = getTargetUrl(request);
const requestBody =
request.method === "GET" || request.method === "HEAD"
? undefined
: await request.arrayBuffer();
const requestHeaders = getRequestHeaders(request, targetUrl);
logProxyRequest(request, targetUrl, requestHeaders, requestBody);
const upstreamStartedAt = performance.now();
const upstreamResponse = await fetch(targetUrl, {
method: request.method,
headers: requestHeaders,
body: requestBody,
cache: "no-store",
});
const upstreamTtfb = performance.now() - upstreamStartedAt;
const responseHeaders = getResponseHeaders(upstreamResponse.headers);
// Force no caching on API proxy responses – always fetch fresh data
responseHeaders.set(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0");
responseHeaders.set(
"Server-Timing",
`upstream-ttfb;dur=${upstreamTtfb.toFixed(1)}`,
);
responseHeaders.set("X-Proxy-Upstream-TTFB-Ms", upstreamTtfb.toFixed(1));
let responseBytes = 0;
const responseBody =
upstreamResponse.body?.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
responseBytes += chunk.byteLength;
controller.enqueue(chunk);
},
flush() {
const total = performance.now() - proxyStartedAt;
logProxyResponse(upstreamResponse, {
upstreamTtfb: Math.round(upstreamTtfb),
upstreamBody: Math.round(total - upstreamTtfb),
proxyTotal: Math.round(total),
responseBytes,
});
},
}),
) ?? null;
return new Response(responseBody, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders,
});
} catch (error) {
if (error instanceof ProxyError) {
return Response.json({ error: error.message }, { status: error.status });
}
console.error("API proxy request failed", error);
return Response.json(
{ error: "API proxy request failed" },
{ status: 502 },
);
}
}
export {
proxyRequest as DELETE,
proxyRequest as GET,
proxyRequest as HEAD,
proxyRequest as OPTIONS,
proxyRequest as PATCH,
proxyRequest as POST,
proxyRequest as PUT,
};