mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(dashboard): prefer public endpoint URLs (#7547)
* fix(dashboard): prefer public endpoint URLs * docs: add changelog fragment for #7547 * test(dashboard): cover onboarding public endpoint * refactor(hooks): split display-URL predicates below complexity gate Decompose isPrivateIpv4 and isPublicDisplayBaseUrl (both over the ESLint complexity gate of 15) into small named predicates. Behavior is unchanged: - isPrivateIpv4 now checks a PRIVATE_IPV4_RANGES table (RFC1918 + special-use ranges) through isInIpv4Range instead of one long chain of ||/&& comparisons. - isPublicDisplayBaseUrl now delegates to isSupportedProtocol, isLoopbackHostname, isMulticastDnsHostname and isNonPublicIpv6 (itself split into isIpv6LoopbackOrUnspecified / isIpv6UniqueLocal / isIpv6LinkLocal), preserving the isIpv6 gate so hostnames that merely start with "fc"/"fd" (e.g. fdroid.example.com) are not misclassified as IPv6 unique-local addresses. Adds IPv4 range-boundary and IPv6-gate regression tests; all existing assertions are unchanged. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
1
changelog.d/fixes/7547-prefer-public-endpoint-url.md
Normal file
1
changelog.d/fixes/7547-prefer-public-endpoint-url.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** Public and managed tunnel endpoints now take precedence over loopback URLs in dashboard setup and copyable API configuration ([#7547](https://github.com/diegosouzapw/OmniRoute/pull/7547)) — thanks @nguyenha935
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import Toggle from "@/shared/components/Toggle";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { useDisplayBaseUrl } from "@/shared/hooks";
|
||||
import { isPublicDisplayBaseUrl, useDisplayBaseUrl } from "@/shared/hooks";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import { getProviderDisplayName } from "@/lib/display/names";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -19,12 +19,7 @@ const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
|
||||
type TranslationValues = Record<string, string | number | boolean | Date>;
|
||||
type CloudflaredTunnelPhase =
|
||||
| "unsupported"
|
||||
| "not_installed"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "error";
|
||||
"unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
|
||||
|
||||
type CloudflaredTunnelStatus = {
|
||||
supported: boolean;
|
||||
@@ -43,12 +38,7 @@ type CloudflaredTunnelStatus = {
|
||||
};
|
||||
|
||||
type TailscaleTunnelPhase =
|
||||
| "unsupported"
|
||||
| "not_installed"
|
||||
| "needs_login"
|
||||
| "stopped"
|
||||
| "running"
|
||||
| "error";
|
||||
"unsupported" | "not_installed" | "needs_login" | "stopped" | "running" | "error";
|
||||
|
||||
type TailscaleTunnelStatus = {
|
||||
supported: boolean;
|
||||
@@ -70,13 +60,7 @@ type TailscaleTunnelStatus = {
|
||||
};
|
||||
|
||||
type NgrokTunnelPhase =
|
||||
| "unsupported"
|
||||
| "not_installed"
|
||||
| "stopped"
|
||||
| "needs_auth"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "error";
|
||||
"unsupported" | "not_installed" | "stopped" | "needs_auth" | "starting" | "running" | "error";
|
||||
|
||||
type NgrokTunnelStatus = {
|
||||
supported: boolean;
|
||||
@@ -187,6 +171,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
const [ngrokToken, setNgrokToken] = useState("");
|
||||
const [showNgrokTunnel, setShowNgrokTunnel] = useState(true);
|
||||
const [expandedTunnel, setExpandedTunnel] = useState<string | null>(null);
|
||||
const [localApiUrl, setLocalApiUrl] = useState("http://localhost:20128/v1");
|
||||
const [lanUrls, setLanUrls] = useState<string[]>([]);
|
||||
const [tailscaleIpUrl, setTailscaleIpUrl] = useState<string | null>(null);
|
||||
const [activeEndpointTab, setActiveEndpointTab] = useState<EndpointTab>("apis");
|
||||
@@ -335,6 +320,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (mounted) {
|
||||
if (data.localUrl) setLocalApiUrl(data.localUrl);
|
||||
setLanUrls(data.lanUrls ?? []);
|
||||
if (data.tailscaleIpUrl) setTailscaleIpUrl(data.tailscaleIpUrl);
|
||||
}
|
||||
@@ -1080,7 +1066,8 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
}, [fetchTailscaleStatus, handleTailscaleEnable, tailscalePassword, translateOrFallback]);
|
||||
|
||||
const displayBaseUrl = useDisplayBaseUrl();
|
||||
const baseUrl = `${displayBaseUrl}/v1`;
|
||||
const displayApiUrl = `${displayBaseUrl}/v1`;
|
||||
const publicDisplayApiUrl = isPublicDisplayBaseUrl(displayBaseUrl) ? displayApiUrl : null;
|
||||
const normalizedCloudBaseUrl = cloudBaseUrl
|
||||
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
|
||||
? `${cloudBaseUrl}/${resolvedMachineId}`
|
||||
@@ -1097,13 +1084,9 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
);
|
||||
}
|
||||
|
||||
// Use new format endpoint (machineId embedded in key)
|
||||
const currentEndpoint = cloudEnabled && cloudEndpointNew ? cloudEndpointNew : baseUrl;
|
||||
|
||||
const activeUrls = [
|
||||
{ label: "Local", url: baseUrl, key: "active_local" },
|
||||
...(cloudEnabled && cloudEndpointNew
|
||||
? [{ label: "Cloud", url: cloudEndpointNew, key: "active_cloud" }]
|
||||
const activeTunnelUrls = [
|
||||
...(publicDisplayApiUrl
|
||||
? [{ label: t("tierPublic"), url: publicDisplayApiUrl, key: "active_public" }]
|
||||
: []),
|
||||
...(cloudflaredStatus?.running && cloudflaredStatus.apiUrl
|
||||
? [{ label: "Cloudflare", url: cloudflaredStatus.apiUrl, key: "active_cf" }]
|
||||
@@ -1121,6 +1104,21 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
? [{ label: "ngrok", url: ngrokStatus.apiUrl, key: "active_ngrok" }]
|
||||
: []),
|
||||
];
|
||||
const preferredTunnelUrl = activeTunnelUrls[0]?.url ?? null;
|
||||
const currentEndpoint =
|
||||
preferredTunnelUrl ??
|
||||
(cloudEnabled && cloudEndpointNew ? cloudEndpointNew : null) ??
|
||||
displayApiUrl;
|
||||
const activeUrls = [
|
||||
...activeTunnelUrls,
|
||||
...(cloudEnabled && cloudEndpointNew
|
||||
? [{ label: "Cloud", url: cloudEndpointNew, key: "active_cloud" }]
|
||||
: []),
|
||||
{ label: "Local", url: localApiUrl, key: "active_local" },
|
||||
].filter(
|
||||
(candidate, index, candidates) =>
|
||||
candidates.findIndex((other) => other.url === candidate.url) === index
|
||||
);
|
||||
const visibleTunnelCount = [showCloudflaredTunnel, showTailscaleFunnel, showNgrokTunnel].filter(
|
||||
Boolean
|
||||
).length;
|
||||
@@ -1357,7 +1355,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
Running
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void copy(baseUrl, "endpoint_url")}
|
||||
onClick={() => void copy(localApiUrl, "endpoint_url")}
|
||||
className="shrink-0 flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-md border border-border/70 text-text-muted hover:text-text hover:border-border transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
|
||||
@@ -29,10 +29,14 @@ vi.mock("next-intl", () => ({
|
||||
"endpoint.showInternal": "Show internal",
|
||||
"endpoint.hideInternal": "Hide internal",
|
||||
"endpoint.vscodeAliasTitle": "VS Code Token Alias",
|
||||
"endpoint.vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.",
|
||||
"endpoint.vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.",
|
||||
"endpoint.vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.",
|
||||
"endpoint.vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.",
|
||||
"endpoint.vscodeAliasDescriptionReady":
|
||||
"Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.",
|
||||
"endpoint.vscodeAliasDescriptionError":
|
||||
"Showing placeholder URLs because CLI keys could not be loaded in this session.",
|
||||
"endpoint.vscodeAliasDescriptionLoading":
|
||||
"Loading CLI keys. Placeholder URLs are shown until a key is available.",
|
||||
"endpoint.vscodeAliasDescriptionPlaceholder":
|
||||
"Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.",
|
||||
"endpoint.vscodeAliasManage": "CLI Tools",
|
||||
"endpoint.vscodeAliasBaseLabel": "VS Code base",
|
||||
"endpoint.vscodeAliasModelsLabel": "VS Code models",
|
||||
@@ -114,6 +118,7 @@ describe("ApiEndpointsTab", () => {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -174,6 +179,7 @@ describe("ApiEndpointsTab", () => {
|
||||
});
|
||||
|
||||
it("renders curl example using window.location.origin when NEXT_PUBLIC_BASE_URL is unset", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
if (input === "/api/cli-tools/keys") {
|
||||
return jsonResponse({ keys: [] });
|
||||
@@ -221,7 +227,7 @@ describe("ApiEndpointsTab", () => {
|
||||
|
||||
expect(
|
||||
expectedOrigins.some((origin) =>
|
||||
renderedText.includes(`curl -X POST ${origin}/v1/chat/completions`)
|
||||
renderedText.includes(`curl -X POST ${origin}/api/v1/chat/completions`)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -186,9 +186,61 @@ describe("EndpointPageClient", () => {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the public browser endpoint before the runtime local endpoint", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "http://localhost:20128");
|
||||
vi.stubGlobal("location", { origin: "https://api.example.com" });
|
||||
|
||||
fetchMock.mockImplementation((input: RequestInfo | URL) => {
|
||||
const path = getRequestPath(input);
|
||||
if (path === "/api/settings") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
cloudEnabled: false,
|
||||
cloudConfigured: false,
|
||||
hideEndpointCloudflaredTunnel: true,
|
||||
hideEndpointTailscaleFunnel: true,
|
||||
hideEndpointNgrokTunnel: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (path === "/api/network/info") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
localUrl: "http://localhost:20131/v1",
|
||||
lanUrls: [],
|
||||
tailscaleIpUrl: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (path === "/v1/models") return Promise.resolve(jsonResponse({ data: [] }));
|
||||
if (path === "/api/mcp/status") return Promise.resolve(jsonResponse({ online: false }));
|
||||
if (path === "/api/a2a/status") {
|
||||
return Promise.resolve(jsonResponse({ status: "ok", tasks: { activeStreams: 0 } }));
|
||||
}
|
||||
if (path === "/api/search/providers") {
|
||||
return Promise.resolve(jsonResponse({ providers: [] }));
|
||||
}
|
||||
if (path === "/api/cli-tools/keys") return Promise.resolve(jsonResponse({ keys: [] }));
|
||||
throw new Error(`Unexpected request: ${path}`);
|
||||
});
|
||||
|
||||
renderEndpointPage();
|
||||
|
||||
await waitForText("https://api.example.com/v1");
|
||||
await waitForText("http://localhost:20131/v1");
|
||||
|
||||
const displayedUrls = Array.from(document.body.querySelectorAll("code")).map(
|
||||
(element) => element.textContent
|
||||
);
|
||||
expect(displayedUrls.indexOf("https://api.example.com/v1")).toBeLessThan(
|
||||
displayedUrls.indexOf("http://localhost:20131/v1")
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the endpoint shell before models finish and skips hidden tunnel probes", async () => {
|
||||
const modelsDeferred = createDeferred<Response>();
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function OnboardingWizard() {
|
||||
const baseUrl = useDisplayBaseUrl();
|
||||
const [step, setStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [apiEndpoint, setApiEndpoint] = useState(`${baseUrl}/api/v1`);
|
||||
const apiEndpoint = `${baseUrl}/api/v1`;
|
||||
|
||||
// Security step state
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -45,20 +45,11 @@ export default function OnboardingWizard() {
|
||||
|
||||
// Check if setup is already complete
|
||||
useEffect(() => {
|
||||
const resolveApiEndpoint = (apiPort) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
const effectiveApiPort = apiPort || 20128;
|
||||
setApiEndpoint(`${protocol}//${hostname}:${effectiveApiPort}/api/v1`);
|
||||
};
|
||||
|
||||
const checkSetup = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const settings = await res.json();
|
||||
resolveApiEndpoint(settings?.apiPort);
|
||||
if (settings.setupComplete) {
|
||||
router.replace("/dashboard");
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
|
||||
|
||||
const { apiPort } = getRuntimePorts();
|
||||
const interfaces = os.networkInterfaces();
|
||||
const localUrl = `http://localhost:${apiPort}/v1`;
|
||||
const lanUrls: string[] = [];
|
||||
let tailscaleIpUrl: string | null = null;
|
||||
|
||||
@@ -28,5 +29,5 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ lanUrls, tailscaleIpUrl });
|
||||
return NextResponse.json({ localUrl, lanUrls, tailscaleIpUrl });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_DISPLAY_BASE_URL } from "../useDisplayBaseUrl";
|
||||
import {
|
||||
DEFAULT_DISPLAY_BASE_URL,
|
||||
isPublicDisplayBaseUrl,
|
||||
resolveDisplayBaseUrl,
|
||||
} from "../useDisplayBaseUrl";
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
@@ -64,6 +68,108 @@ describe("useDisplayBaseUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies public domains separately from local and private addresses", () => {
|
||||
expect(isPublicDisplayBaseUrl("https://api.example.com")).toBe(true);
|
||||
expect(isPublicDisplayBaseUrl("http://localhost:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://192.168.1.25:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://100.88.4.55:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://[::1]:20128")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies IPv4 private ranges at their exact boundaries", () => {
|
||||
const cases: Array<[host: string, expectedPublic: boolean]> = [
|
||||
// 0.0.0.0/8 ("this" network) vs just above it
|
||||
["0.0.0.1", false],
|
||||
["1.0.0.1", true],
|
||||
// RFC1918 10.0.0.0/8 vs just outside
|
||||
["9.255.255.255", true],
|
||||
["10.0.0.1", false],
|
||||
["11.0.0.0", true],
|
||||
// loopback 127.0.0.0/8 vs just outside
|
||||
["126.255.255.255", true],
|
||||
["127.0.0.1", false],
|
||||
["128.0.0.0", true],
|
||||
// multicast/reserved 224.0.0.0+ vs just below
|
||||
["223.255.255.255", true],
|
||||
["224.0.0.1", false],
|
||||
// CGNAT RFC6598 100.64.0.0/10 vs just outside
|
||||
["100.63.255.255", true],
|
||||
["100.64.0.0", false],
|
||||
["100.127.255.255", false],
|
||||
["100.128.0.0", true],
|
||||
// link-local 169.254.0.0/16 vs just outside
|
||||
["169.253.255.255", true],
|
||||
["169.254.0.1", false],
|
||||
["169.255.0.0", true],
|
||||
// RFC1918 172.16.0.0/12 vs just outside
|
||||
["172.15.255.255", true],
|
||||
["172.16.0.0", false],
|
||||
["172.31.255.255", false],
|
||||
["172.32.0.0", true],
|
||||
// RFC1918 192.168.0.0/16 vs just outside
|
||||
["192.167.255.255", true],
|
||||
["192.168.0.1", false],
|
||||
["192.169.0.0", true],
|
||||
];
|
||||
|
||||
for (const [host, expectedPublic] of cases) {
|
||||
expect(isPublicDisplayBaseUrl(`http://${host}:20128`)).toBe(expectedPublic);
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies IPv6 special ranges while keeping the check scoped to actual IPv6 hosts", () => {
|
||||
expect(isPublicDisplayBaseUrl("http://[::]:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://[::1]:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://[fc00::1]:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://[fd12::1]:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://[fe80::1]:20128")).toBe(false);
|
||||
expect(isPublicDisplayBaseUrl("http://[2001:db8::1]:20128")).toBe(true);
|
||||
// A hostname that merely STARTS WITH "fd"/"fc" must stay public — the ULA/
|
||||
// link-local checks are IPv6-only and must not leak into hostname matching.
|
||||
expect(isPublicDisplayBaseUrl("http://fdroid.example.com:20128")).toBe(true);
|
||||
expect(isPublicDisplayBaseUrl("http://fcbar.example.com:20128")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a configured public URL when the browser is on a local address", () => {
|
||||
expect(resolveDisplayBaseUrl("https://api.example.com/", "http://localhost:20128")).toBe(
|
||||
"https://api.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the currently reachable public origin over another configured URL", () => {
|
||||
expect(resolveDisplayBaseUrl("https://old.example.com", "https://api.example.com/")).toBe(
|
||||
"https://api.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers a public browser origin over a loopback build-time value", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "http://localhost:20128");
|
||||
vi.stubGlobal("location", { origin: "https://api.example.com" });
|
||||
|
||||
const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
|
||||
function C() {
|
||||
const url = useDisplayBaseUrl();
|
||||
return <span data-testid="value">{url}</span>;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<C />);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
|
||||
DEFAULT_DISPLAY_BASE_URL
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
|
||||
"https://api.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_DISPLAY_BASE_URL on first render and origin after mount when env unset", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Shared Hooks - Export all
|
||||
export { useTheme } from "./useTheme";
|
||||
export { useDisplayBaseUrl, DEFAULT_DISPLAY_BASE_URL } from "./useDisplayBaseUrl";
|
||||
export {
|
||||
useDisplayBaseUrl,
|
||||
DEFAULT_DISPLAY_BASE_URL,
|
||||
isPublicDisplayBaseUrl,
|
||||
resolveDisplayBaseUrl,
|
||||
} from "./useDisplayBaseUrl";
|
||||
|
||||
@@ -10,13 +10,122 @@ function normalizeUrl(value?: string): string | null {
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* One RFC1918 / special-use IPv4 range, expressed as closed intervals on the
|
||||
* first two octets. Unbounded ends use +/-Infinity so a single numeric
|
||||
* comparison covers them without an extra branch.
|
||||
*/
|
||||
interface Ipv4Range {
|
||||
readonly firstMin: number;
|
||||
readonly firstMax: number;
|
||||
readonly secondMin: number;
|
||||
readonly secondMax: number;
|
||||
}
|
||||
|
||||
/** RFC1918 + special-use IPv4 ranges treated as non-public for display purposes. */
|
||||
const PRIVATE_IPV4_RANGES: readonly Ipv4Range[] = [
|
||||
{ firstMin: 0, firstMax: 0, secondMin: -Infinity, secondMax: Infinity }, // 0.0.0.0/8 ("this" network)
|
||||
{ firstMin: 10, firstMax: 10, secondMin: -Infinity, secondMax: Infinity }, // RFC1918 10.0.0.0/8
|
||||
{ firstMin: 127, firstMax: 127, secondMin: -Infinity, secondMax: Infinity }, // loopback 127.0.0.0/8
|
||||
{ firstMin: 224, firstMax: Infinity, secondMin: -Infinity, secondMax: Infinity }, // multicast/reserved/broadcast
|
||||
{ firstMin: 100, firstMax: 100, secondMin: 64, secondMax: 127 }, // CGNAT RFC6598 100.64.0.0/10
|
||||
{ firstMin: 169, firstMax: 169, secondMin: 254, secondMax: 254 }, // link-local 169.254.0.0/16
|
||||
{ firstMin: 172, firstMax: 172, secondMin: 16, secondMax: 31 }, // RFC1918 172.16.0.0/12
|
||||
{ firstMin: 192, firstMax: 192, secondMin: 168, secondMax: 168 }, // RFC1918 192.168.0.0/16
|
||||
];
|
||||
|
||||
function isInIpv4Range(first: number, second: number, range: Ipv4Range): boolean {
|
||||
return (
|
||||
first >= range.firstMin &&
|
||||
first <= range.firstMax &&
|
||||
second >= range.secondMin &&
|
||||
second <= range.secondMax
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpv4(hostname: string): boolean {
|
||||
const octets = hostname.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false;
|
||||
const [first, second] = octets;
|
||||
return PRIVATE_IPV4_RANGES.some((range) => isInIpv4Range(first, second, range));
|
||||
}
|
||||
|
||||
function isSupportedProtocol(protocol: string): boolean {
|
||||
return protocol === "http:" || protocol === "https:";
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
return !hostname || hostname === "localhost" || hostname.endsWith(".localhost");
|
||||
}
|
||||
|
||||
function isMulticastDnsHostname(hostname: string): boolean {
|
||||
return hostname.endsWith(".local");
|
||||
}
|
||||
|
||||
function isIpv6LoopbackOrUnspecified(hostname: string): boolean {
|
||||
return hostname === "::" || hostname === "::1";
|
||||
}
|
||||
|
||||
function isIpv6UniqueLocal(hostname: string): boolean {
|
||||
// RFC 4193 Unique Local Addresses: fc00::/7 (prefixes "fc" and "fd").
|
||||
return hostname.startsWith("fc") || hostname.startsWith("fd");
|
||||
}
|
||||
|
||||
const IPV6_LINK_LOCAL_PATTERN = /^fe[89ab]/;
|
||||
|
||||
function isIpv6LinkLocal(hostname: string): boolean {
|
||||
// RFC 4291 link-local: fe80::/10.
|
||||
return IPV6_LINK_LOCAL_PATTERN.test(hostname);
|
||||
}
|
||||
|
||||
/** Combines the IPv6-specific non-public checks the caller gates on `isIpv6`. */
|
||||
function isNonPublicIpv6(hostname: string): boolean {
|
||||
return (
|
||||
isIpv6LoopbackOrUnspecified(hostname) ||
|
||||
isIpv6UniqueLocal(hostname) ||
|
||||
isIpv6LinkLocal(hostname)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPublicDisplayBaseUrl(value?: string): boolean {
|
||||
const normalized = normalizeUrl(value);
|
||||
if (!normalized) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
if (!isSupportedProtocol(parsed.protocol)) return false;
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (isLoopbackHostname(hostname)) return false;
|
||||
if (isMulticastDnsHostname(hostname) || isPrivateIpv4(hostname)) return false;
|
||||
|
||||
// IPv6-only checks stay gated on isIpv6 — hostnames like "fdroid.example.com"
|
||||
// legitimately start with "fd" and must not be misclassified as ULA addresses.
|
||||
const isIpv6 = hostname.includes(":");
|
||||
if (isIpv6 && isNonPublicIpv6(hostname)) return false;
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDisplayBaseUrl(envValue?: string, browserOrigin?: string): string {
|
||||
const configuredUrl = normalizeUrl(envValue);
|
||||
const currentOrigin = normalizeUrl(browserOrigin);
|
||||
|
||||
if (currentOrigin && isPublicDisplayBaseUrl(currentOrigin)) return currentOrigin;
|
||||
if (configuredUrl && isPublicDisplayBaseUrl(configuredUrl)) return configuredUrl;
|
||||
return currentOrigin ?? configuredUrl ?? DEFAULT_DISPLAY_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public base URL to display in the dashboard.
|
||||
*
|
||||
* Resolution chain:
|
||||
* 1. NEXT_PUBLIC_BASE_URL (env, trimmed + slash-normalized) — wins if set.
|
||||
* 2. window.location.origin after client mount — when env is unset.
|
||||
* 3. DEFAULT_DISPLAY_BASE_URL ("http://localhost:20128") — SSR / first render fallback.
|
||||
* Resolution chain after client mount:
|
||||
* 1. Public browser origin — proves the current tunnel/domain is reachable.
|
||||
* 2. Public NEXT_PUBLIC_BASE_URL — keeps a configured public URL when opened locally.
|
||||
* 3. Current browser origin, configured URL, then localhost as local fallbacks.
|
||||
*
|
||||
* DISPLAY ONLY — do NOT use this hook for OAuth `redirect_uri`.
|
||||
* OAuth callers must read `process.env.NEXT_PUBLIC_BASE_URL` directly to avoid
|
||||
@@ -29,8 +138,7 @@ export function useDisplayBaseUrl(): string {
|
||||
const [url, setUrl] = useState<string>(envValue ?? DEFAULT_DISPLAY_BASE_URL);
|
||||
|
||||
useEffect(() => {
|
||||
if (envValue) return;
|
||||
const origin = normalizeUrl(window.location.origin) ?? DEFAULT_DISPLAY_BASE_URL;
|
||||
const resolvedUrl = resolveDisplayBaseUrl(envValue ?? undefined, window.location.origin);
|
||||
// Schedule via queueMicrotask so setState is called inside a callback,
|
||||
// not synchronously in the effect body (react-hooks/set-state-in-effect).
|
||||
// The unmounted guard prevents a stale setState on a torn-down root
|
||||
@@ -38,7 +146,7 @@ export function useDisplayBaseUrl(): string {
|
||||
// before the microtask fires on the first effect invocation).
|
||||
let unmounted = false;
|
||||
queueMicrotask(() => {
|
||||
if (!unmounted) setUrl(origin);
|
||||
if (!unmounted) setUrl(resolvedUrl);
|
||||
});
|
||||
return () => {
|
||||
unmounted = true;
|
||||
|
||||
107
tests/unit/ui/onboarding-public-endpoint.test.tsx
Normal file
107
tests/unit/ui/onboarding-public-endpoint.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { pushMock, replaceMock } = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
replace: replaceMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks", () => ({
|
||||
useDisplayBaseUrl: () => "https://api.example.com",
|
||||
}));
|
||||
|
||||
const { default: OnboardingWizard } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/onboarding/page");
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
async function waitForText(text: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (container.textContent?.includes(text)) return;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
throw new Error(`Timed out waiting for text: ${text}`);
|
||||
}
|
||||
|
||||
function findButton(label: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === label
|
||||
);
|
||||
if (!button) throw new Error(`Button not found: ${label}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
async function clickButton(label: string): Promise<void> {
|
||||
await act(async () => {
|
||||
findButton(label).click();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
pushMock.mockReset();
|
||||
replaceMock.mockReset();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === "/api/settings") {
|
||||
return jsonResponse({ setupComplete: false, apiPort: 20131 });
|
||||
}
|
||||
throw new Error(`Unexpected request: ${String(input)}`);
|
||||
})
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the public API URL instead of rebuilding an internal apiPort URL", async () => {
|
||||
await act(async () => {
|
||||
root.render(<OnboardingWizard />);
|
||||
});
|
||||
|
||||
await waitForText("getStarted");
|
||||
await clickButton("getStarted");
|
||||
await clickButton("skip");
|
||||
await clickButton("skip");
|
||||
await clickButton("skip");
|
||||
await clickButton("skip");
|
||||
|
||||
await waitForText("https://api.example.com/api/v1");
|
||||
expect(container.textContent).toContain("https://api.example.com/api/v1");
|
||||
expect(container.textContent).not.toContain(":20131/api/v1");
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
Reference in New Issue
Block a user