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.
303 lines
7.3 KiB
303 lines
7.3 KiB
import type { NextRequest } from "next/server";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|
|
|
|
const PROXY_PATH_PARAM = "__proxyPath";
|
|
|
|
const REQUEST_HEADERS_TO_FORWARD = [
|
|
"accept",
|
|
"accept-language",
|
|
"authorization",
|
|
"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" ||
|
|
(process.env.LOG_API_PROXY !== "false" &&
|
|
process.env.NODE_ENV !== "production");
|
|
|
|
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 getRequestHeaders(request: NextRequest, targetUrl: URL) {
|
|
const headers = new Headers();
|
|
const authKey = process.env.NEXT_PUBLIC_AUTH_KEY;
|
|
|
|
for (const header of REQUEST_HEADERS_TO_FORWARD) {
|
|
const value = request.headers.get(header);
|
|
|
|
if (value) {
|
|
headers.set(header, value);
|
|
}
|
|
}
|
|
|
|
if (authKey) {
|
|
headers.set("authorization", `token ${authKey}`);
|
|
}
|
|
|
|
headers.set("accept-encoding", "identity");
|
|
headers.set("http_x_user_language", "en");
|
|
headers.set("origin", targetUrl.origin);
|
|
headers.set("platform", "android");
|
|
headers.set("referer", `${targetUrl.origin}/`);
|
|
headers.set("user-agent", "dart:io");
|
|
|
|
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,
|
|
responseBody: ArrayBuffer,
|
|
) {
|
|
if (!shouldLogProxy) {
|
|
return;
|
|
}
|
|
|
|
writeProxyLog("response", {
|
|
status: upstreamResponse.status,
|
|
statusText: upstreamResponse.statusText,
|
|
headers: headersToObject(upstreamResponse.headers),
|
|
body: getBodyLogValue(
|
|
responseBody,
|
|
upstreamResponse.headers.get("content-type") ?? undefined,
|
|
),
|
|
response: {
|
|
status: upstreamResponse.status,
|
|
statusText: upstreamResponse.statusText,
|
|
headers: headersToObject(upstreamResponse.headers),
|
|
body: getBodyLogValue(
|
|
responseBody,
|
|
upstreamResponse.headers.get("content-type") ?? undefined,
|
|
),
|
|
},
|
|
});
|
|
}
|
|
|
|
function writeProxyLog(label: string, value: unknown) {
|
|
console.log(`[api-proxy] ${label} ${JSON.stringify(value)}`);
|
|
}
|
|
|
|
async function proxyRequest(request: NextRequest) {
|
|
try {
|
|
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 upstreamResponse = await fetch(targetUrl, {
|
|
method: request.method,
|
|
headers: requestHeaders,
|
|
body: requestBody,
|
|
cache: "no-store",
|
|
});
|
|
const responseBody = await upstreamResponse.arrayBuffer();
|
|
|
|
logProxyResponse(upstreamResponse, responseBody);
|
|
|
|
return new Response(responseBody, {
|
|
status: upstreamResponse.status,
|
|
statusText: upstreamResponse.statusText,
|
|
headers: getResponseHeaders(upstreamResponse.headers),
|
|
});
|
|
} 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,
|
|
};
|