Files
OmniRoute/src/shared/hooks/useDisplayBaseUrl.ts
Rouzbeh† 8115867c2d fix(dashboard): include OMNIROUTE_BASE_PATH in displayed API base URL (#8514)
* fix(dashboard): include OMNIROUTE_BASE_PATH in displayed API base URL

When OmniRoute is served under a reverse-proxy subpath (OMNIROUTE_BASE_PATH),
the Endpoints UI built display URLs from window.location.origin alone and
appended /v1, producing https://host/v1 instead of https://host/omniroute/v1.

- Prefer NEXT_PUBLIC_BASE_URL when it already includes a non-root path
- Append NEXT_PUBLIC_OMNIROUTE_BASE_PATH (mirrored from OMNIROUTE_BASE_PATH
  at build time) when resolving a bare public origin
- Document subpath display behavior in .env.example
- Add unit coverage for basePath-aware resolution

* docs(changelog): add fragment for #8514 display basePath fix

---------

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-07-26 12:10:49 -03:00

263 lines
9.3 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
export const DEFAULT_DISPLAY_BASE_URL = "http://localhost:20128";
function normalizeUrl(value?: string): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
return trimmed.replace(/\/+$/, "");
}
/**
* Normalize a Next.js-style basePath to a leading-slash form without a trailing slash.
* Empty / root values become `""`.
*/
export function normalizeBasePath(value?: string | null): string {
const trimmed = value?.trim() ?? "";
if (!trimmed || trimmed === "/") return "";
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withSlash.replace(/\/+$/, "");
}
/**
* Resolve the deploy basePath for display URLs (e.g. `/omniroute`).
*
* Priority:
* 1. Explicit `envBasePath` argument / `NEXT_PUBLIC_OMNIROUTE_BASE_PATH`
* 2. Non-root path of `configuredBaseUrl` (`NEXT_PUBLIC_BASE_URL`)
*/
export function resolveDeployBasePath(
envBasePath?: string | null,
configuredBaseUrl?: string | null,
browserPathname?: string | null
): string {
const fromEnv = normalizeBasePath(
envBasePath ??
(typeof process !== "undefined"
? process.env.NEXT_PUBLIC_OMNIROUTE_BASE_PATH || process.env.OMNIROUTE_BASE_PATH
: undefined)
);
if (fromEnv) return fromEnv;
const configured = normalizeUrl(configuredBaseUrl ?? undefined);
if (configured) {
try {
const path = normalizeBasePath(new URL(configured).pathname);
if (path) return path;
} catch {
/* ignore invalid URL */
}
}
// browserPathname kept for API symmetry with resolveDisplayBaseUrl; basePath
// must come from env / configured URL so root deploys never invent a prefix.
void browserPathname;
return "";
}
/**
* 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;
}
}
function urlPathname(value: string): string {
try {
return normalizeBasePath(new URL(value).pathname);
} catch {
return "";
}
}
function joinOriginAndBasePath(origin: string, basePath: string): string {
if (!basePath) return origin;
return `${origin.replace(/\/+$/, "")}${basePath}`;
}
/**
* Resolve the public origin (+ optional basePath) shown in the dashboard.
*
* @param envValue `NEXT_PUBLIC_BASE_URL` (may include a path for subpath deploys)
* @param browserOrigin `window.location.origin`
* @param browserPathname `window.location.pathname` (still includes Next basePath)
* @param envBasePath `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` / `OMNIROUTE_BASE_PATH`
*/
export function resolveDisplayBaseUrl(
envValue?: string,
browserOrigin?: string,
browserPathname?: string,
envBasePath?: string
): string {
const configuredUrl = normalizeUrl(envValue);
const currentOrigin = normalizeUrl(browserOrigin);
const basePath = resolveDeployBasePath(envBasePath, configuredUrl, browserPathname);
// Configured public URL that already includes a non-root path wins (subpath deploy).
// Example: NEXT_PUBLIC_BASE_URL=https://host/omniroute must not be collapsed to
// https://host when the browser origin is the same host without a path.
if (configuredUrl && isPublicDisplayBaseUrl(configuredUrl) && urlPathname(configuredUrl)) {
return configuredUrl;
}
// Reachable public browser origin, with basePath re-applied when configured.
// Existing behavior: prefer the live origin over a different configured *host*
// (tunnels / alternate domains). basePath keeps /v1 examples correct under
// reverse-proxy subpaths (OMNIROUTE_BASE_PATH).
if (currentOrigin && isPublicDisplayBaseUrl(currentOrigin)) {
return joinOriginAndBasePath(currentOrigin, basePath);
}
// Configured public URL without a path — append basePath if present.
if (configuredUrl && isPublicDisplayBaseUrl(configuredUrl)) {
return joinOriginAndBasePath(configuredUrl, basePath);
}
const fallback = currentOrigin ?? configuredUrl ?? DEFAULT_DISPLAY_BASE_URL;
return joinOriginAndBasePath(fallback, basePath);
}
/**
* Returns the public base URL to display in the dashboard.
*
* Resolution chain after client mount:
* 1. Public browser origin (+ OMNIROUTE_BASE_PATH when set) — proves the
* current tunnel/domain is reachable.
* 2. Public NEXT_PUBLIC_BASE_URL (path-preserving when it includes a subpath).
* 3. Local fallbacks (current origin / configured URL / localhost).
*
* DISPLAY ONLY — do NOT use this hook for OAuth `redirect_uri`.
* OAuth callers must read `process.env.NEXT_PUBLIC_BASE_URL` directly to avoid
* host-header attack surface. For server-side resolution, use
* `src/shared/utils/resolveOmniRouteBaseUrl.ts` instead.
*/
export function useDisplayBaseUrl(): string {
const envValue = normalizeUrl(process.env.NEXT_PUBLIC_BASE_URL);
const envBasePath = normalizeBasePath(
process.env.NEXT_PUBLIC_OMNIROUTE_BASE_PATH || process.env.OMNIROUTE_BASE_PATH
);
const [url, setUrl] = useState<string>(() =>
resolveDisplayBaseUrl(envValue ?? undefined, undefined, undefined, envBasePath || undefined)
);
useEffect(() => {
const resolvedUrl = resolveDisplayBaseUrl(
envValue ?? undefined,
window.location.origin,
window.location.pathname,
envBasePath || undefined
);
// 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
// (relevant under React strict mode's double-invoke, where cleanup runs
// before the microtask fires on the first effect invocation).
let unmounted = false;
queueMicrotask(() => {
if (!unmounted) setUrl(resolvedUrl);
});
return () => {
unmounted = true;
};
}, [envValue, envBasePath]);
return url;
}