import { NextRequest } from "next/server"; const DEFAULT_API_BASE = "https://api.openwork.software"; const DEFAULT_AUTH_ORIGIN = "https://app.openwork.software"; const DEFAULT_AUTH_FALLBACK_BASE = "https://den-control-plane-openwork.onrender.com"; const NO_BODY_STATUS = new Set([204, 205, 304]); const apiBase = normalizeBaseUrl(process.env.DEN_API_BASE ?? DEFAULT_API_BASE); const authOrigin = normalizeBaseUrl(process.env.DEN_AUTH_ORIGIN ?? DEFAULT_AUTH_ORIGIN); const authFallbackBase = normalizeBaseUrl(process.env.DEN_AUTH_FALLBACK_BASE ?? DEFAULT_AUTH_FALLBACK_BASE); type ProxyOptions = { routePrefix: string; upstreamPathPrefix?: string; rewriteAuthLocationsToRequestOrigin?: boolean; }; function normalizeBaseUrl(value: string): string { return value.trim().replace(/\/+$/, ""); } function normalizePathPrefix(value: string): string { return value.replace(/^\/+|\/+$/g, ""); } function getTargetPath(request: NextRequest, segments: string[], routePrefix: string): string { const incoming = new URL(request.url); let targetPath = segments.join("/"); if (!targetPath) { const normalizedPrefix = routePrefix.endsWith("/") ? routePrefix : `${routePrefix}/`; if (incoming.pathname.startsWith(normalizedPrefix)) { targetPath = incoming.pathname.slice(normalizedPrefix.length); } else if (incoming.pathname === routePrefix) { targetPath = ""; } } return targetPath; } function buildTargetUrl( base: string, request: NextRequest, targetPath: string, upstreamPathPrefix = "", ): string { const incoming = new URL(request.url); const prefixedPath = [normalizePathPrefix(upstreamPathPrefix), targetPath].filter(Boolean).join("/"); const upstream = new URL(prefixedPath ? `${base}/${prefixedPath}` : base); upstream.search = incoming.search; return upstream.toString(); } function isLikelyHtmlBody(body: ArrayBuffer): boolean { if (body.byteLength === 0) { return false; } const preview = new TextDecoder().decode(body.slice(0, 256)).trim().toLowerCase(); return preview.startsWith(" string[] }).getSetCookie; if (typeof getSetCookie === "function") { const cookies = getSetCookie.call(upstreamHeaders); for (const cookie of cookies) { if (cookie) { responseHeaders.append("set-cookie", cookie); } } return; } const cookie = upstreamHeaders.get("set-cookie"); if (cookie) { responseHeaders.append("set-cookie", cookie); } } function buildHeaders(request: NextRequest, contentType: string | null): Headers { const headers = new Headers(); const copyHeaders = [ "accept", "authorization", "cookie", "user-agent", "x-requested-with", "origin", "x-forwarded-for", ]; for (const key of copyHeaders) { const value = request.headers.get(key); if (value) { headers.set(key, value); } } if (contentType) { headers.set("content-type", contentType); } if (!headers.has("accept")) { headers.set("accept", "application/json"); } if (!headers.has("origin")) { headers.set("origin", authOrigin); } const incoming = new URL(request.url); headers.set("x-forwarded-host", request.headers.get("host") ?? incoming.host); headers.set("x-forwarded-proto", incoming.protocol.replace(/:$/, "")); return headers; } async function fetchUpstream( request: NextRequest, targetUrl: string, contentType: string | null, body: Uint8Array | null, ): Promise<{ response: Response; body: ArrayBuffer }> { const init: RequestInit = { method: request.method, headers: buildHeaders(request, contentType), redirect: "manual", }; if (body && request.method !== "GET" && request.method !== "HEAD") { init.body = body; } const response = await fetch(targetUrl, init); const responseBody = await response.arrayBuffer(); return { response, body: responseBody }; } function rewriteLocationHeader(location: string, request: NextRequest): string { let parsedLocation: URL; try { parsedLocation = new URL(location); } catch { return location; } const requestOrigin = new URL(request.url).origin; const rewriteableOrigins = [apiBase, authFallbackBase] .map((value) => { try { return new URL(value).origin; } catch { return null; } }) .filter((value): value is string => Boolean(value)); if (!rewriteableOrigins.includes(parsedLocation.origin) || !parsedLocation.pathname.startsWith("/api/auth/")) { return location; } return `${requestOrigin}${parsedLocation.pathname}${parsedLocation.search}${parsedLocation.hash}`; } export async function proxyUpstream( request: NextRequest, segments: string[] = [], options: ProxyOptions, ): Promise { const targetPath = getTargetPath(request, segments, options.routePrefix); const primaryTargetUrl = buildTargetUrl(apiBase, request, targetPath, options.upstreamPathPrefix); const fallbackTargetUrl = buildTargetUrl(authFallbackBase, request, targetPath, options.upstreamPathPrefix); const contentType = request.headers.get("content-type"); const requestBody = request.method !== "GET" && request.method !== "HEAD" ? new Uint8Array(await request.arrayBuffer()) : null; let upstream: Response | null = null; let body: ArrayBuffer | null = null; try { const primary = await fetchUpstream(request, primaryTargetUrl, contentType, requestBody); upstream = primary.response; body = primary.body; } catch { if (apiBase !== authFallbackBase) { try { const fallback = await fetchUpstream(request, fallbackTargetUrl, contentType, requestBody); upstream = fallback.response; body = fallback.body; } catch {} } } if (!upstream || !body) { return buildUpstreamErrorResponse(502, "Upstream request failed."); } if (apiBase !== authFallbackBase && shouldFallbackToAuthBase(upstream, body)) { try { const fallback = await fetchUpstream(request, fallbackTargetUrl, contentType, requestBody); upstream = fallback.response; body = fallback.body; } catch {} } const responseContentType = upstream.headers.get("content-type")?.toLowerCase() ?? ""; if (upstream.status >= 500 && (responseContentType.includes("text/html") || isLikelyHtmlBody(body))) { return buildUpstreamErrorResponse(upstream.status, "Upstream service unavailable."); } const responseHeaders = new Headers(); const passThroughHeaders = ["content-type", "location", "cache-control"]; for (const key of passThroughHeaders) { const value = upstream.headers.get(key); if (!value) { continue; } if (key === "location" && options.rewriteAuthLocationsToRequestOrigin) { responseHeaders.set(key, rewriteLocationHeader(value, request)); continue; } responseHeaders.set(key, value); } copySetCookieHeaders(upstream.headers, responseHeaders); const shouldDropBody = request.method === "HEAD" || NO_BODY_STATUS.has(upstream.status); return new Response(shouldDropBody ? null : body, { status: upstream.status, headers: responseHeaders, }); }