3 changed files with 3 additions and 347 deletions
-
303src/app/api/proxy/route.ts
-
6src/components/questions/question-answer-storage.tsx
-
41src/lib/http.ts
@ -1,303 +0,0 @@ |
|||
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, |
|||
}; |
|||
@ -1,47 +1,10 @@ |
|||
import axios, { type InternalAxiosRequestConfig } from "axios"; |
|||
|
|||
const PROXY_PATH_PARAM = "__proxyPath"; |
|||
import axios from "axios"; |
|||
|
|||
export const http = axios.create({ |
|||
baseURL: "/api/proxy", |
|||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, |
|||
headers: { |
|||
Accept: "application/json", |
|||
"Content-Type": "application/json", |
|||
}, |
|||
withCredentials: true, |
|||
}); |
|||
|
|||
function isAbsoluteUrl(url: string) { |
|||
return /^[a-z][a-z\d+\-.]*:\/\//i.test(url); |
|||
} |
|||
|
|||
function withProxyPathParam( |
|||
params: InternalAxiosRequestConfig["params"], |
|||
proxyPath: string, |
|||
) { |
|||
if (params instanceof URLSearchParams) { |
|||
const nextParams = new URLSearchParams(params); |
|||
nextParams.set(PROXY_PATH_PARAM, proxyPath); |
|||
return nextParams; |
|||
} |
|||
|
|||
if (typeof params === "string") { |
|||
const nextParams = new URLSearchParams(params); |
|||
nextParams.set(PROXY_PATH_PARAM, proxyPath); |
|||
return nextParams; |
|||
} |
|||
|
|||
return { |
|||
...(params && typeof params === "object" ? params : {}), |
|||
[PROXY_PATH_PARAM]: proxyPath, |
|||
}; |
|||
} |
|||
|
|||
http.interceptors.request.use((config) => { |
|||
if (config.url && !isAbsoluteUrl(config.url)) { |
|||
config.params = withProxyPathParam(config.params, config.url); |
|||
config.url = ""; |
|||
} |
|||
|
|||
return config; |
|||
}); |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue