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({ |
export const http = axios.create({ |
||||
baseURL: "/api/proxy", |
|
||||
|
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, |
||||
headers: { |
headers: { |
||||
Accept: "application/json", |
Accept: "application/json", |
||||
"Content-Type": "application/json", |
"Content-Type": "application/json", |
||||
}, |
}, |
||||
withCredentials: true, |
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