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.
397 lines
11 KiB
397 lines
11 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",
|
|
"security-key",
|
|
"x-security-key",
|
|
"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}`);
|
|
}
|
|
|
|
// Set security-key if available in environment and not already in headers
|
|
const securityKey =
|
|
process.env.NEXT_PUBLIC_SECURITY_KEY ||
|
|
process.env.SECURITY_KEY ||
|
|
"t5yugymks5458fd4ghfg6h6";
|
|
if (securityKey && !headers.has("security-key")) {
|
|
headers.set("security-key", securityKey);
|
|
}
|
|
|
|
// 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,
|
|
};
|