diff --git a/README.md b/README.md index cd365a68d..2d0eafd75 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ Create an `.env` file with the following: DATABASE_URL=connection-url ``` +Optional: set `API_URL` to change the base URL used by internal UI API calls. +Relative paths are served under `BASE_PATH`; absolute URLs are called directly by the browser. +For example, `API_URL=/internal-api` or `API_URL=https://api.example.com/api`. + The connection URL format: ```bash diff --git a/next.config.ts b/next.config.ts index ab7fbf484..c9c717f94 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,7 @@ const TRACKER_SCRIPT = '/script.js'; const isProd = process.env.NODE_ENV === 'production'; +const apiUrl = process.env.API_URL || ''; const basePath = process.env.BASE_PATH || ''; const cloudMode = process.env.CLOUD_MODE || ''; const cloudUrl = process.env.CLOUD_URL || ''; @@ -22,12 +23,32 @@ const trackerScriptURL = process.env.TRACKER_SCRIPT_URL || ''; const selfTrack = process.env.UMAMI_SELF_TRACK || ''; const selfRecord = process.env.UMAMI_SELF_RECORD || ''; +function getUrlOrigin(url: string) { + try { + return new URL(url).origin; + } catch { + return ''; + } +} + +function isRelativeUrl(url: string) { + return Boolean(url && !/^https?:\/\//i.test(url)); +} + +function normalizePath(url: string) { + return `/${url.replace(/^\/+|\/+$/g, '')}`; +} + +const apiUrlOrigin = getUrlOrigin(apiUrl); +const connectSrc = ["'self'", 'https:', apiUrlOrigin].filter(Boolean).join(' '); + const contentSecurityPolicy = ` default-src 'self'; img-src 'self' https: data:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; - connect-src 'self' https:; + connect-src ${connectSrc}; + frame-src 'self' http: https:; frame-ancestors 'self' ${frameAncestors}; `; @@ -122,6 +143,22 @@ if (collectApiEndpoint) { }); } +if (isRelativeUrl(apiUrl)) { + const normalizedApiUrl = normalizePath(apiUrl); + + if (normalizedApiUrl !== '/' && normalizedApiUrl !== '/api') { + headers.push({ + source: `${normalizedApiUrl}/:path*`, + headers: apiHeaders, + }); + + rewrites.push({ + source: `${normalizedApiUrl}/:path*`, + destination: '/api/:path*', + }); + } +} + const redirects = [ { source: '/teams/:id/dashboard/edit', @@ -187,6 +224,7 @@ if (isProd && cloudMode) { export default withNextIntl({ reactStrictMode: false, env: { + apiUrl, basePath, cloudMode, cloudUrl, diff --git a/podman/env.sample b/podman/env.sample index 06435e56b..832161a04 100644 --- a/podman/env.sample +++ b/podman/env.sample @@ -10,6 +10,11 @@ DATABASE_TYPE=postgresql # A secret string used by Umami (replace with a strong random string) APP_SECRET=replace-me-with-a-random-string +# Optional API base URL for internal UI API calls. Relative paths are served under BASE_PATH; +# absolute URLs are called directly by the browser. +# Examples: /internal-api or https://api.example.com/api +API_URL= + # Postgres container defaults. POSTGRES_DB=umami POSTGRES_USER=umami diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts index d89e6f18b..aaef8fced 100644 --- a/src/components/hooks/useApi.ts +++ b/src/components/hooks/useApi.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { usePathname } from 'next/navigation'; import { useCallback } from 'react'; +import { getApiUrl } from '@/lib/api-url'; import { getClientAuthToken } from '@/lib/client'; import { SHARE_CONTEXT_HEADER, SHARE_TOKEN_HEADER } from '@/lib/constants'; import { type FetchResponse, httpDelete, httpGet, httpPost, httpPut } from '@/lib/fetch'; @@ -31,10 +32,8 @@ export function useApi() { authorization: `Bearer ${getClientAuthToken()}`, ...shareHeaders, }; - const basePath = process.env.basePath; - const getUrl = (url: string) => { - return url.startsWith('http') ? url : `${basePath || ''}/api${url}`; + return getApiUrl(url); }; const getHeaders = (headers: any = {}) => { diff --git a/src/lib/api-url.ts b/src/lib/api-url.ts new file mode 100644 index 000000000..104634fde --- /dev/null +++ b/src/lib/api-url.ts @@ -0,0 +1,50 @@ +type ApiUrlOptions = { + apiUrl?: string; + basePath?: string; +}; + +const APP_ROUTE_PATTERNS: RegExp[] = [ + /^\/auth(\/|$)/, + /^\/config(\/|$)/, +]; + +function trimTrailingSlash(value: string) { + return value.replace(/\/+$/, ''); +} + +function trimLeadingSlash(value: string) { + return value.replace(/^\/+/, ''); +} + +function joinPath(base: string, path: string) { + if (!base) { + return `/${trimLeadingSlash(path)}`; + } + + return `${trimTrailingSlash(base)}/${trimLeadingSlash(path)}`; +} + +function isAbsoluteUrl(url: string) { + return /^https?:\/\//i.test(url); +} + +function isAppRoute(url: string) { + const path = `/${trimLeadingSlash(url.split('?')[0])}`; + return APP_ROUTE_PATTERNS.some(re => re.test(path)); +} + +export function getApiUrl(url: string, options: ApiUrlOptions = {}) { + if (isAbsoluteUrl(url)) { + return url; + } + + const { apiUrl = process.env.apiUrl || '', basePath = process.env.basePath || '' } = options; + const useApiUrl = apiUrl && !isAppRoute(url); + const baseUrl = useApiUrl + ? isAbsoluteUrl(apiUrl) + ? apiUrl + : joinPath(basePath, apiUrl) + : joinPath(basePath, '/api'); + + return joinPath(baseUrl, url); +}