feat: support reverse proxy path prefix deployments (#257)

* feat: support reverse proxy path prefixes

* fix: respect BASE_URL in SAML callback fallback

* fix: make BASE_URL runtime configurable
This commit is contained in:
ARUNAVO RAY
2026-04-09 12:32:59 +05:30
committed by GitHub
parent c87513b648
commit 01a3b08dac
58 changed files with 552 additions and 114 deletions

View File

@@ -1,5 +1,7 @@
import { withBase } from "@/lib/base-path";
// Base API URL
const API_BASE = "/api";
const API_BASE = withBase("/api");
// Helper function for API requests
async function apiRequest<T>(

View File

@@ -3,6 +3,12 @@ import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
import { withBase } from "@/lib/base-path";
function normalizeAuthBaseUrl(url: string): string {
const validatedUrl = new URL(url.trim());
return validatedUrl.origin;
}
export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
@@ -18,9 +24,8 @@ export const authClient = createAuthClient({
// Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') {
try {
// Validate URL format and remove trailing slash
const validatedUrl = new URL(url.trim());
return validatedUrl.origin; // Use origin to ensure clean URL without path
// Validate URL format and preserve optional base path
return normalizeAuthBaseUrl(url);
} catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
}
@@ -34,7 +39,7 @@ export const authClient = createAuthClient({
// Default for SSR - always return a valid URL
return 'http://localhost:4321';
})(),
basePath: '/api/auth', // Explicitly set the base path
basePath: withBase('/api/auth'), // Explicitly set the base path
plugins: [
oidcClient(),
ssoClient(),

View File

@@ -5,6 +5,7 @@ import { sso } from "@better-auth/sso";
import { db, users } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
import { withBase } from "./base-path";
/**
* Resolves the list of trusted origins for Better Auth CSRF validation.
@@ -97,7 +98,7 @@ export const auth = betterAuth({
try {
// Validate URL format and ensure it's a proper origin
const validatedUrl = new URL(url.trim());
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
const cleanUrl = validatedUrl.origin;
console.info('Using BETTER_AUTH_URL:', cleanUrl);
return cleanUrl;
} catch (e) {
@@ -107,7 +108,7 @@ export const auth = betterAuth({
return defaultUrl;
}
})(),
basePath: "/api/auth", // Specify the base path for auth endpoints
basePath: withBase("/api/auth"), // Specify the base path for auth endpoints
// Trusted origins - this is how we support multiple access URLs.
// Uses the function form so that the origin can be auto-detected from
@@ -150,8 +151,8 @@ export const auth = betterAuth({
plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({
loginPage: "/login",
consentPage: "/oauth/consent",
loginPage: withBase("/login"),
consentPage: withBase("/oauth/consent"),
// Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true,
// Note: trustedClients would be configured here if Better Auth supports it

86
src/lib/base-path.test.ts Normal file
View File

@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test } from "bun:test";
const originalBaseUrl = process.env.BASE_URL;
const originalWindow = (globalThis as { window?: unknown }).window;
async function loadModule(baseUrl?: string, runtimeWindowBasePath?: string) {
if (baseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = baseUrl;
}
if (runtimeWindowBasePath === undefined) {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
const restoredWindow = (globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window;
if (typeof restoredWindow === "object" && restoredWindow !== null) {
delete restoredWindow.__GITEA_MIRROR_BASE_PATH__;
}
}
} else {
(globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window = {
__GITEA_MIRROR_BASE_PATH__: runtimeWindowBasePath,
};
}
return import(`./base-path.ts?case=${encodeURIComponent(baseUrl ?? "default")}-${Date.now()}-${Math.random()}`);
}
afterEach(() => {
if (originalBaseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = originalBaseUrl;
}
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
}
});
describe("base-path helpers", () => {
test("defaults to root paths", async () => {
const mod = await loadModule(undefined);
expect(mod.BASE_PATH).toBe("/");
expect(mod.withBase("/api/health")).toBe("/api/health");
expect(mod.withBase("repositories")).toBe("/repositories");
expect(mod.stripBasePath("/config")).toBe("/config");
});
test("normalizes prefixed base paths", async () => {
const mod = await loadModule("mirror/");
expect(mod.BASE_PATH).toBe("/mirror");
expect(mod.withBase("/api/health")).toBe("/mirror/api/health");
expect(mod.withBase("repositories")).toBe("/mirror/repositories");
expect(mod.stripBasePath("/mirror/config")).toBe("/config");
expect(mod.stripBasePath("/mirror")).toBe("/");
});
test("keeps absolute URLs unchanged", async () => {
const mod = await loadModule("/mirror");
expect(mod.withBase("https://example.com/path")).toBe("https://example.com/path");
});
test("uses browser runtime base path when process env is unset", async () => {
const mod = await loadModule(undefined, "/runtime");
expect(mod.BASE_PATH).toBe("/runtime");
expect(mod.withBase("/api/health")).toBe("/runtime/api/health");
expect(mod.stripBasePath("/runtime/config")).toBe("/config");
});
test("prefers process env base path over browser runtime value", async () => {
const mod = await loadModule("/env", "/runtime");
expect(mod.BASE_PATH).toBe("/env");
expect(mod.withBase("/api/health")).toBe("/env/api/health");
});
});

83
src/lib/base-path.ts Normal file
View File

@@ -0,0 +1,83 @@
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
const BASE_PATH_WINDOW_KEY = "__GITEA_MIRROR_BASE_PATH__";
function normalizeBasePath(basePath: string | null | undefined): string {
if (!basePath) {
return "/";
}
let normalized = basePath.trim();
if (!normalized) {
return "/";
}
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, "");
return normalized || "/";
}
function resolveRuntimeBasePath(): string {
if (typeof process !== "undefined" && typeof process.env?.BASE_URL === "string") {
return normalizeBasePath(process.env.BASE_URL);
}
if (typeof window !== "undefined") {
const runtimeBasePath = (window as Window & { [BASE_PATH_WINDOW_KEY]?: string })[BASE_PATH_WINDOW_KEY];
if (typeof runtimeBasePath === "string") {
return normalizeBasePath(runtimeBasePath);
}
}
return "/";
}
export function getBasePath(): string {
return resolveRuntimeBasePath();
}
export const BASE_PATH = getBasePath();
export { BASE_PATH_WINDOW_KEY };
export function withBase(path: string): string {
const basePath = getBasePath();
if (!path) {
return basePath === "/" ? "/" : `${basePath}/`;
}
if (URL_SCHEME_REGEX.test(path) || path.startsWith("//")) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (basePath === "/") {
return normalizedPath;
}
return `${basePath}${normalizedPath}`;
}
export function stripBasePath(pathname: string): string {
const basePath = getBasePath();
if (!pathname) {
return "/";
}
if (basePath === "/") {
return pathname;
}
if (pathname === basePath || pathname === `${basePath}/`) {
return "/";
}
if (pathname.startsWith(`${basePath}/`)) {
return pathname.slice(basePath.length) || "/";
}
return pathname;
}

View File

@@ -2,8 +2,9 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { httpRequest, HttpError } from "@/lib/http-client";
import type { RepoStatus } from "@/types/Repository";
import { withBase } from "@/lib/base-path";
export const API_BASE = "/api";
export const API_BASE = withBase("/api");
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));