mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
* 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>
263 lines
9.3 KiB
TypeScript
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;
|
|
}
|