From 14411e0cf864c210e3d38d873a1c0ce93b87f998 Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Sun, 13 Sep 2026 11:47:37 +0700 Subject: [PATCH] feat(api): add resolveApiExampleUrl function and related tests for dynamic API URL resolution --- src/app/api-keys/page.tsx | 23 ++++++++++++- src/lib/api-example-url.ts | 62 +++++++++++++++++++++++++++++++++++ tests/api-example-url.test.ts | 47 ++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/lib/api-example-url.ts create mode 100644 tests/api-example-url.test.ts diff --git a/src/app/api-keys/page.tsx b/src/app/api-keys/page.tsx index 5370135..595c79c 100644 --- a/src/app/api-keys/page.tsx +++ b/src/app/api-keys/page.tsx @@ -7,10 +7,13 @@ import SearchField from "@/components/SearchField"; import TablePagination from "@/components/TablePagination"; import { apiKeys as apiKeysApi, + getApiBaseUrl, + settingsApi, type ApiKeyExpiryOption, type ApiKeyRecord, type CreateApiKeyBody, } from "@/lib/api"; +import { resolveApiExampleUrl } from "@/lib/api-example-url"; import { useTablePagination } from "@/lib/use-table-pagination"; import { Activity, @@ -172,6 +175,7 @@ export default function ApiKeysPage() { const [generatedKey, setGeneratedKey] = useState( null, ); + const [panelUrl, setPanelUrl] = useState(null); const [sessionRawKeys, setSessionRawKeys] = useState>( {}, ); @@ -212,8 +216,16 @@ export default function ApiKeysPage() { useEffect(() => { void loadKeys(); + void loadPanelUrl(); }, []); + const apiExampleUrl = resolveApiExampleUrl({ + panelUrl, + browserOrigin: + typeof window === "undefined" ? null : window.location.origin, + apiBaseUrl: getApiBaseUrl(), + }); + async function loadKeys() { setLoading(true); setError(""); @@ -347,6 +359,15 @@ export default function ApiKeysPage() { }); } + async function loadPanelUrl() { + try { + const response = await settingsApi.get(); + setPanelUrl(response.data.general.panelUrl); + } catch { + // The active browser origin remains a valid same-origin API fallback. + } + } + async function removeKey(id: string) { setRemovingId(id); setError(""); @@ -1136,7 +1157,7 @@ export default function ApiKeysPage() { fontFamily: "JetBrains Mono, monospace", }} > - {`curl -H "x-api-key: ${generatedKey.rawKey}" http://localhost:4000/api/v1/servers`} + {`curl -H "x-api-key: ${generatedKey.rawKey}" ${apiExampleUrl}`} diff --git a/src/lib/api-example-url.ts b/src/lib/api-example-url.ts new file mode 100644 index 0000000..289bbcc --- /dev/null +++ b/src/lib/api-example-url.ts @@ -0,0 +1,62 @@ +type ApiExampleUrlOptions = { + panelUrl?: string | null; + browserOrigin?: string | null; + apiBaseUrl?: string | null; +}; + +function normalizeHttpUrl(value: string | null | undefined) { + const trimmed = value?.trim(); + if (!trimmed) return null; + + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + + return parsed; + } catch { + return null; + } +} + +function isLoopbackHost(hostname: string) { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" + ); +} + +export function resolveApiExampleUrl({ + panelUrl, + browserOrigin, + apiBaseUrl, +}: ApiExampleUrlOptions) { + const storedPanelUrl = normalizeHttpUrl(panelUrl); + const runtimeOrigin = normalizeHttpUrl(browserOrigin); + const storedPanelIsStaleLocalDefault = + storedPanelUrl && + runtimeOrigin && + isLoopbackHost(storedPanelUrl.hostname) && + !isLoopbackHost(runtimeOrigin.hostname); + + const publicOrigin = storedPanelIsStaleLocalDefault + ? runtimeOrigin + : storedPanelUrl || runtimeOrigin; + + if (publicOrigin) { + return new URL("/api/v1/servers", publicOrigin.origin).toString(); + } + + const configuredApiBase = normalizeHttpUrl(apiBaseUrl); + if (configuredApiBase) { + return new URL( + `${configuredApiBase.pathname.replace(/\/+$/, "")}/servers`, + configuredApiBase.origin, + ).toString(); + } + + return "http://localhost:3000/api/v1/servers"; +} diff --git a/tests/api-example-url.test.ts b/tests/api-example-url.test.ts new file mode 100644 index 0000000..f686cf2 --- /dev/null +++ b/tests/api-example-url.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveApiExampleUrl } from "../src/lib/api-example-url"; + +test("uses the Panel Access URL stored in settings", () => { + assert.equal( + resolveApiExampleUrl({ + panelUrl: "https://panel.example.com/", + browserOrigin: "http://192.168.1.20:3000", + apiBaseUrl: "http://localhost:4000/api/v1", + }), + "https://panel.example.com/api/v1/servers", + ); +}); + +test("uses the active browser origin when the stored panel URL is a stale loopback default", () => { + assert.equal( + resolveApiExampleUrl({ + panelUrl: "http://localhost:3000", + browserOrigin: "http://192.168.1.20:3000", + apiBaseUrl: "http://localhost:4000/api/v1", + }), + "http://192.168.1.20:3000/api/v1/servers", + ); +}); + +test("keeps the stored loopback URL during local access", () => { + assert.equal( + resolveApiExampleUrl({ + panelUrl: "http://localhost:3000", + browserOrigin: "http://127.0.0.1:3000", + apiBaseUrl: "http://localhost:4000/api/v1", + }), + "http://localhost:3000/api/v1/servers", + ); +}); + +test("falls back to the configured API base when no public panel origin is available", () => { + assert.equal( + resolveApiExampleUrl({ + panelUrl: "not-a-url", + browserOrigin: null, + apiBaseUrl: "https://api.example.com/api/v1/", + }), + "https://api.example.com/api/v1/servers", + ); +});