Add configurable internal API URL

This commit is contained in:
Mike Cao
2026-05-09 01:06:52 -07:00
parent fa182d0947
commit 2fd319f910
6 changed files with 124 additions and 4 deletions
+4
View File
@@ -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
+38 -1
View File
@@ -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,31 @@ 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};
`;
@@ -123,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',
@@ -188,6 +224,7 @@ if (isProd && cloudMode) {
export default withNextIntl({
reactStrictMode: false,
env: {
apiUrl,
basePath,
cloudMode,
cloudUrl,
+5
View File
@@ -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
+2 -3
View File
@@ -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 = {}) => {
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from '@jest/globals';
import { getApiUrl } from '../api-url';
test('uses the default api path', () => {
expect(getApiUrl('/websites', { apiUrl: '', basePath: '' })).toBe('/api/websites');
});
test('uses basePath with the default api path', () => {
expect(getApiUrl('/websites', { apiUrl: '', basePath: '/analytics' })).toBe(
'/analytics/api/websites',
);
});
test('uses a relative API_URL', () => {
expect(getApiUrl('/websites', { apiUrl: '/backend/api', basePath: '' })).toBe(
'/backend/api/websites',
);
});
test('uses basePath with a relative API_URL', () => {
expect(getApiUrl('/websites', { apiUrl: '/backend/api', basePath: '/analytics' })).toBe(
'/analytics/backend/api/websites',
);
});
test('uses an absolute API_URL directly', () => {
expect(getApiUrl('/websites', { apiUrl: 'https://api.example.com/api', basePath: '' })).toBe(
'https://api.example.com/api/websites',
);
});
test('leaves absolute request URLs unchanged', () => {
expect(getApiUrl('https://example.com/custom', { apiUrl: '/backend/api', basePath: '' })).toBe(
'https://example.com/custom',
);
});
+39
View File
@@ -0,0 +1,39 @@
type ApiUrlOptions = {
apiUrl?: string;
basePath?: string;
};
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);
}
export function getApiUrl(url: string, options: ApiUrlOptions = {}) {
if (isAbsoluteUrl(url)) {
return url;
}
const { apiUrl = process.env.apiUrl || '', basePath = process.env.basePath || '' } = options;
const baseUrl = apiUrl
? isAbsoluteUrl(apiUrl)
? apiUrl
: joinPath(basePath, apiUrl)
: joinPath(basePath, '/api');
return joinPath(baseUrl, url);
}