diff --git a/package-lock.json b/package-lock.json index a1774e760..7bea5dc06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@flanksource/flanksource-ui", - "version": "1.0.707", + "version": "1.0.719", "dependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@clerk/nextjs": "^5.2.2", @@ -61,6 +61,7 @@ "monaco-editor": "0.48.0", "monaco-themes": "0.4.4", "monaco-yaml": "5.1.1", + "next-http-proxy-middleware": "^1.2.6", "object-hash": "^3.0.0", "prism-react-renderer": "^1.3.3", "prop-types": "^15.8.1", @@ -13112,6 +13113,14 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, + "node_modules/@types/http-proxy": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.3.tgz", + "integrity": "sha512-wIPqXANye5BbORbuh74exbwNzj+UWCwWyeEFJzUQ7Fq3W2NSAy+7x7nX1fgbEypr2/TdKqpeuxLnXWgzN533/Q==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/intercom-web": { "version": "2.8.24", "resolved": "https://registry.npmjs.org/@types/intercom-web/-/intercom-web-2.8.24.tgz", @@ -21906,7 +21915,6 @@ "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", @@ -27730,6 +27738,18 @@ } } }, + "node_modules/next-http-proxy-middleware": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/next-http-proxy-middleware/-/next-http-proxy-middleware-1.2.6.tgz", + "integrity": "sha512-vHmtFLeO+HomU4Fx/CoA4MbLnXya1B17yR5qOmpYZqRjzGa17a9dgXh9ONvquSZdMrIn7bUfjoPLxMkYMtKj3Q==", + "dependencies": { + "@types/http-proxy": "1.17.3", + "http-proxy": "^1.18.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", diff --git a/package.json b/package.json index 351b5c40a..598717788 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "monaco-editor": "0.48.0", "monaco-themes": "0.4.4", "monaco-yaml": "5.1.1", + "next-http-proxy-middleware": "^1.2.6", "object-hash": "^3.0.0", "prism-react-renderer": "^1.3.3", "prop-types": "^15.8.1", diff --git a/pages/api/[...paths].ts b/pages/api/[...paths].ts new file mode 100644 index 000000000..06a5a6abe --- /dev/null +++ b/pages/api/[...paths].ts @@ -0,0 +1,82 @@ +import { clerkClient, getAuth } from "@clerk/nextjs/server"; +import { NextApiRequest, NextApiResponse } from "next"; +import httpProxyMiddleware from "next-http-proxy-middleware"; + +const API_URL = process.env.BACKEND_URL; +const isCanary = process.env.NEXT_PUBLIC_APP_DEPLOYMENT === "CANARY_CHECKER"; +const env = process.env.ENV; +const isClerkAuth = process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true"; + +const canaryPrefix = isCanary ? "" : "/canary"; + +export const config = { + api: { + bodyParser: false + } +}; + +async function getTargetURL(req: NextApiRequest) { + if (isClerkAuth) { + const user = getAuth(req); + const org = await clerkClient.organizations.getOrganization({ + organizationId: user.sessionClaims?.org_id! + }); + const backendURL = org?.publicMetadata?.backend_url; + // for now, lets fallback to the old way of doing things, if the backend_url + // is not set in the org metadata + const target = backendURL; + return target; + } + return API_URL; +} + +const clerkBackendPathRewrites = [ + { + patternStr: "^/api", + replaceStr: "/" + } +]; + +const kratosBackendPathRewrites = ["localhost", "netlify"].includes(env!) + ? [ + { + patternStr: "^/api", + replaceStr: "/api" + } + ] + : [ + { + patternStr: "^/api/canary", + replaceStr: `${canaryPrefix}` + }, + { + patternStr: "^/api/.ory", + replaceStr: "/kratos/" + }, + { + patternStr: "^/api", + replaceStr: "/" + } + ]; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + console.log("Proxying request to backend"); + const target = await getTargetURL(req); + + if (!target) { + return res.status(500).json({ error: "Missing target" }); + } + + console.log(`Proxying to ${target}`); + + return httpProxyMiddleware(req, res, { + target: target!, + xfwd: true, + pathRewrite: [ + ...(isClerkAuth ? clerkBackendPathRewrites : kratosBackendPathRewrites) + ] + }); +} \ No newline at end of file diff --git a/src/api/axios.ts b/src/api/axios.ts index b1ec0bebf..194e5fefa 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -3,9 +3,7 @@ import { toastError } from "../components/Toast/toast"; const isClerkAuthSystem = !!process.env.NEXT_PUBLIC_AUTH_IS_CLERK === true; -// The base URL for the API is either / or /api depending on the auth system, -// for clerk the base URL is /, for the rest it is /api -const API_BASE = isClerkAuthSystem ? "" : "/api"; +const API_BASE = "/api"; export const apiBase = axios.create({ baseURL: `${API_BASE}`, @@ -147,17 +145,14 @@ for (const client of [ Rback, Snapshot ]) { - // only attach the interceptor if the system is not clerk - if (!isClerkAuthSystem) { - client.interceptors.response.use( - (response) => response, - (error) => { - redirectToLoginPageOnSessionExpiry(error); - toastError(error.response.data.message); - return Promise.reject(error); - } - ); - } + client.interceptors.response.use( + (response) => response, + (error) => { + redirectToLoginPageOnSessionExpiry(error); + toastError(error.response.data.message); + return Promise.reject(error); + } + ); } export function redirectToLoginPageOnSessionExpiry(error: AxiosError) { diff --git a/src/hooks/useClerkAttachAuthInterceptorsToAxios.tsx b/src/hooks/useClerkAttachAuthInterceptorsToAxios.tsx index 611b3c565..f752ffddd 100644 --- a/src/hooks/useClerkAttachAuthInterceptorsToAxios.tsx +++ b/src/hooks/useClerkAttachAuthInterceptorsToAxios.tsx @@ -24,6 +24,19 @@ const allAxiosInstances = [ Snapshot ]; +function parseBoolean(value: unknown): boolean { + if (typeof value === "string") { + return value.toLowerCase() === "true"; + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "undefined" || value === null) { + return false; + } + throw new Error("Invalid boolean value"); +} + export default function useClerkAttachAuthInterceptorsToAxios() { const { getToken } = useAuth(); const { organization } = useOrganization(); @@ -32,7 +45,19 @@ export default function useClerkAttachAuthInterceptorsToAxios() { // organization set const backendUrl = organization?.publicMetadata.backend_url as string; + // we need to know if th organization supports direct access to the backend or + // not so we can set the base URL correctly + const direct = parseBoolean(organization?.publicMetadata.direct); + + console.log({ direct }); + useEffect(() => { + // if the organization does not support direct access to the backend, we + // should not attach the interceptors + if (!direct) { + return; + } + const interceptorsRequestCleanups: number[] = []; const interceptorsResponseCleanups: number[] = []; @@ -51,7 +76,9 @@ export default function useClerkAttachAuthInterceptorsToAxios() { config.headers.Authorization = `Bearer ${token}`; } // set the base URL to the organization's backend URL, not - const currentBaseUrl = config.baseURL; + // the API base URL + // For direct access, the base path does not include /api + const currentBaseUrl = config.baseURL?.replaceAll("/api", ""); config.baseURL = new URL( currentBaseUrl ?? "/", //current base url is probably something like /api/auth or /api/db backendUrl @@ -89,5 +116,5 @@ export default function useClerkAttachAuthInterceptorsToAxios() { }); }); }; - }, [backendUrl, getToken]); + }, [backendUrl, direct, getToken]); }