From 01a3b08daca702c300f505562f44dfd36d7e20ba Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Thu, 9 Apr 2026 12:32:59 +0530 Subject: [PATCH] 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 --- .env.example | 8 ++ Dockerfile | 5 +- README.md | 14 ++- docker-compose.alt.yml | 8 +- docker-compose.dev.yml | 7 +- docker-compose.yml | 12 ++- docker-entrypoint.sh | 8 +- docs/ENVIRONMENT_VARIABLES.md | 17 ++++ package.json | 4 +- scripts/runtime-server.ts | 76 ++++++++++++++++ src/components/NotFound.tsx | 11 +-- src/components/activity/ActivityLog.tsx | 3 +- src/components/auth/LoginForm.tsx | 12 ++- src/components/auth/SignupForm.tsx | 5 +- src/components/config/ConfigTabs.tsx | 17 ++-- .../config/NotificationSettings.tsx | 3 +- src/components/config/SSOSettings.tsx | 16 ++-- src/components/dashboard/Dashboard.tsx | 3 +- src/components/dashboard/RecentActivity.tsx | 5 +- src/components/dashboard/RepositoryList.tsx | 5 +- src/components/layout/Header.tsx | 7 +- src/components/layout/MainLayout.tsx | 5 +- src/components/layout/Sidebar.tsx | 11 +-- .../organizations/OrganizationsList.tsx | 7 +- src/components/repositories/Repository.tsx | 3 +- .../repositories/RepositoryTable.tsx | 3 +- src/components/theme/ThemeScript.astro | 21 ++++- src/hooks/useAuth-legacy.ts | 7 +- src/hooks/useAuth.ts | 9 +- src/hooks/useSEE.ts | 3 +- src/hooks/useSyncRepo.ts | 3 +- src/layouts/main.astro | 3 +- src/lib/api.ts | 4 +- src/lib/auth-client.ts | 13 ++- src/lib/auth.ts | 9 +- src/lib/base-path.test.ts | 86 +++++++++++++++++++ src/lib/base-path.ts | 83 ++++++++++++++++++ src/lib/utils.ts | 3 +- src/middleware.ts | 35 +++++++- src/pages/404.astro | 5 +- src/pages/activity.astro | 3 +- src/pages/api/auth/[...all].ts | 11 ++- src/pages/api/auth/sso/register.ts | 5 +- src/pages/auth-error.astro | 7 +- src/pages/config.astro | 3 +- src/pages/docs/advanced.astro | 21 +++-- src/pages/docs/architecture.astro | 3 +- src/pages/docs/authentication.astro | 3 +- src/pages/docs/ca-certificates.astro | 3 +- src/pages/docs/configuration.astro | 6 +- src/pages/docs/index.astro | 5 +- src/pages/docs/quickstart.astro | 9 +- src/pages/index.astro | 5 +- src/pages/login.astro | 5 +- src/pages/oauth/consent.astro | 7 +- src/pages/organizations.astro | 3 +- src/pages/repositories.astro | 3 +- src/pages/signup.astro | 5 +- 58 files changed, 552 insertions(+), 114 deletions(-) create mode 100644 scripts/runtime-server.ts create mode 100644 src/lib/base-path.test.ts create mode 100644 src/lib/base-path.ts diff --git a/.env.example b/.env.example index aa43c6e..6b33689 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ NODE_ENV=production HOST=0.0.0.0 PORT=4321 +# Optional application base path (use "/" for root, or "/mirror" for subpath deployments) +BASE_URL=/ # Database Configuration # For self-hosted, SQLite is used by default @@ -31,6 +33,12 @@ BETTER_AUTH_URL=http://localhost:4321 # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com # +# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set: +# BASE_URL=/mirror +# BETTER_AUTH_URL=https://git.example.com +# PUBLIC_BETTER_AUTH_URL=https://git.example.com +# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com +# # BETTER_AUTH_URL - Used server-side for auth callbacks and redirects # PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls # BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests diff --git a/Dockerfile b/Dockerfile index f809791..0d51df4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY . . RUN bun run build RUN mkdir -p dist/scripts && \ for script in scripts/*.ts; do \ + if [ "$(basename "$script")" = "runtime-server.ts" ]; then continue; fi; \ bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \ done @@ -59,6 +60,7 @@ COPY --from=pruner /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh +COPY --from=builder /app/scripts/runtime-server.ts ./scripts/runtime-server.ts COPY --from=builder /app/drizzle ./drizzle # Remove build-only packages that are not needed at runtime @@ -73,6 +75,7 @@ ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=4321 ENV DATABASE_URL=file:data/gitea-mirror.db +ENV BASE_URL=/ # Create directories and setup permissions RUN mkdir -p /app/certs && \ @@ -90,6 +93,6 @@ VOLUME /app/data EXPOSE 4321 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1 + CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1' ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 75276df..6660da4 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,19 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes ### Reverse Proxy Configuration -If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference. +If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure: + +```bash +BASE_URL=/mirror +BETTER_AUTH_URL=https://git.example.com +PUBLIC_BETTER_AUTH_URL=https://git.example.com +BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com +``` + +Notes: +- `BASE_URL` sets the application path prefix. +- `BETTER_AUTH_TRUSTED_ORIGINS` should contain origins only (no path). +- `BASE_URL` is runtime configuration, so prebuilt registry images can be reused across different subpaths. ### Mirror Token Rotation (GitHub Token Changed) diff --git a/docker-compose.alt.yml b/docker-compose.alt.yml index 296bfba..90b83df 100644 --- a/docker-compose.alt.yml +++ b/docker-compose.alt.yml @@ -22,6 +22,7 @@ services: # BETTER_AUTH_URL=https://gitea-mirror.example.com # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com + # Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL. # === CORE SETTINGS === # These are technically required but have working defaults @@ -29,6 +30,7 @@ services: - DATABASE_URL=file:data/gitea-mirror.db - HOST=0.0.0.0 - PORT=4321 + - BASE_URL=${BASE_URL:-/} - PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321} # Optional concurrency controls (defaults match in-app defaults) # If you want perfect ordering of issues and PRs, set these at 1 @@ -36,7 +38,11 @@ services: - MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5} healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] + test: + [ + "CMD-SHELL", + "BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"", + ] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b7b97d5..8811b8f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -66,6 +66,7 @@ services: - DATABASE_URL=file:data/gitea-mirror.db - HOST=0.0.0.0 - PORT=4321 + - BASE_URL=${BASE_URL:-/} - BETTER_AUTH_SECRET=dev-secret-key # GitHub/Gitea Mirror Config - GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username} @@ -89,7 +90,11 @@ services: # Optional: Skip TLS verification (insecure, use only for testing) # - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false} healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"] + test: + [ + "CMD-SHELL", + "BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"", + ] interval: 30s timeout: 5s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index c278b44..1c647dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: - DATABASE_URL=file:data/gitea-mirror.db - HOST=0.0.0.0 - PORT=4321 + - BASE_URL=${BASE_URL:-/} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production} - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321} # REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik), @@ -37,6 +38,11 @@ services: # BETTER_AUTH_URL=https://gitea-mirror.example.com # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com + # If deployed under a path prefix (e.g. https://git.example.com/mirror), also set: + # BASE_URL=/mirror + # BETTER_AUTH_URL=https://git.example.com + # PUBLIC_BETTER_AUTH_URL=https://git.example.com + # BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com - PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-} # Optional: ENCRYPTION_SECRET will be auto-generated if not provided @@ -81,7 +87,11 @@ services: - HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false} - HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-} healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] + test: + [ + "CMD-SHELL", + "BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"", + ] interval: 30s timeout: 10s retries: 5 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5698269..50e24f0 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -229,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP # Start the application echo "Starting Gitea Mirror..." -bun ./dist/server/entry.mjs & +if [ -f "./scripts/runtime-server.ts" ]; then + bun ./scripts/runtime-server.ts & +elif [ -f "./dist/scripts/runtime-server.js" ]; then + bun ./dist/scripts/runtime-server.js & +else + bun ./dist/server/entry.mjs & +fi APP_PID=$! # Wait for the application to finish diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 6a83963..533b218 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -33,6 +33,7 @@ Essential application settings required for running Gitea Mirror. | `NODE_ENV` | Application environment | `production` | No | | `HOST` | Server host binding | `0.0.0.0` | No | | `PORT` | Server port | `4321` | No | +| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No | | `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No | | `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes | | `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No | @@ -302,6 +303,7 @@ services: environment: # Core Configuration - NODE_ENV=production + - BASE_URL=/ - DATABASE_URL=file:data/gitea-mirror.db - BETTER_AUTH_SECRET=your-secure-secret-here # Primary access URL: @@ -370,6 +372,21 @@ This setup allows you to: **Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons. +### Path Prefix Deployments + +If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set: + +```bash +BASE_URL=/mirror +BETTER_AUTH_URL=https://git.example.com +PUBLIC_BETTER_AUTH_URL=https://git.example.com +BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com +``` + +Notes: +- `BETTER_AUTH_TRUSTED_ORIGINS` must contain origins only (no path). +- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes. + ### Trusted Origins The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes: diff --git a/package.json b/package.json index e40bb78..a82d263 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "test-shutdown": "bun scripts/test-graceful-shutdown.ts", "test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup", "preview": "bunx --bun astro preview", - "start": "bun dist/server/entry.mjs", - "start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs", + "start": "bun scripts/runtime-server.ts", + "start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts", "test": "bun test", "test:migrations": "bun scripts/validate-migrations.ts", "test:watch": "bun test --watch", diff --git a/scripts/runtime-server.ts b/scripts/runtime-server.ts new file mode 100644 index 0000000..4fa5cc2 --- /dev/null +++ b/scripts/runtime-server.ts @@ -0,0 +1,76 @@ +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +function normalizeBasePath(basePath: string | undefined): string { + if (!basePath || !basePath.trim()) { + return "/"; + } + + let normalized = basePath.trim(); + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + + normalized = normalized.replace(/\/+$/, ""); + return normalized || "/"; +} + +function rewriteRequestUrl(rawUrl: string, basePath: string): string | null { + if (basePath === "/") { + return rawUrl; + } + + const url = new URL(rawUrl, "http://localhost"); + const pathname = url.pathname; + + if (pathname === basePath || pathname === `${basePath}/`) { + url.pathname = "/"; + return `${url.pathname}${url.search}`; + } + + if (pathname.startsWith(`${basePath}/`)) { + url.pathname = pathname.slice(basePath.length) || "/"; + return `${url.pathname}${url.search}`; + } + + return null; +} + +const basePath = normalizeBasePath(process.env.BASE_URL); +const host = process.env.HOST || "0.0.0.0"; +const port = Number.parseInt(process.env.PORT || "4321", 10); + +process.env.ASTRO_NODE_AUTOSTART = "disabled"; +const { handler } = await import("../dist/server/entry.mjs"); + +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + if (!req.url) { + res.statusCode = 400; + res.end("Bad Request"); + return; + } + + const rewrittenUrl = rewriteRequestUrl(req.url, basePath); + if (rewrittenUrl === null) { + res.statusCode = 404; + res.end("Not Found"); + return; + } + + req.url = rewrittenUrl; + req.headers["x-gitea-mirror-base-rewritten"] = "1"; + + Promise.resolve((handler as unknown as (request: IncomingMessage, response: ServerResponse) => unknown)(req, res)).catch((error) => { + console.error("Unhandled runtime server error:", error); + if (!res.headersSent) { + res.statusCode = 500; + res.end("Internal Server Error"); + } else { + res.end(); + } + }); +}); + +server.listen(port, host, () => { + console.log(`Runtime server listening on http://${host}:${port} (BASE_URL=${basePath})`); +}); diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx index 8dee1cb..4e63ea2 100644 --- a/src/components/NotFound.tsx +++ b/src/components/NotFound.tsx @@ -1,6 +1,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react"; +import { withBase } from "@/lib/base-path"; export function NotFound() { return ( @@ -21,7 +22,7 @@ export function NotFound() { {/* Action Buttons */}
@@ -27,7 +28,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {

@@ -54,7 +55,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) { Configure your GitHub connection to start mirroring repositories.

) : ( diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 0cb44a7..acfae13 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -14,6 +14,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { withBase } from "@/lib/base-path"; interface HeaderProps { currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log"; @@ -101,14 +102,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, )}
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 0582547..6530f25 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner"; import { useAuth } from "@/hooks/useAuth"; import { useRepoSync } from "@/hooks/useSyncRepo"; import { useConfigStatus } from "@/hooks/useConfigStatus"; +import { stripBasePath, withBase } from "@/lib/base-path"; // Navigation context to signal when navigation happens const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 }); @@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) { // Handle browser back/forward navigation useEffect(() => { const handlePopState = () => { - const path = window.location.pathname; + const path = stripBasePath(window.location.pathname); const pageMap: Record = { '/': 'dashboard', '/repositories': 'repositories', @@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) { if (!authLoading && !user) { // Use window.location for client-side redirect if (typeof window !== 'undefined') { - window.location.href = '/login'; + window.location.href = withBase('/login'); } return null; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 5af231c..344f97f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -9,6 +9,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { stripBasePath, withBase } from "@/lib/base-path"; interface SidebarProps { className?: string; @@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on useEffect(() => { // Hydration happens here - const path = window.location.pathname; + const path = stripBasePath(window.location.pathname); setCurrentPath(path); }, []); // Listen for URL changes (browser back/forward) useEffect(() => { const handlePopState = () => { - setCurrentPath(window.location.pathname); + setCurrentPath(stripBasePath(window.location.pathname)); }; window.addEventListener('popstate', handlePopState); @@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on if (currentPath === href) return; // Update URL without page reload - window.history.pushState({}, '', href); + window.history.pushState({}, '', withBase(href)); setCurrentPath(href); // Map href to page name for the parent component @@ -163,7 +164,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on Check out the documentation for help with setup and configuration.

{ // Call API to update organization destination - const response = await fetch(`/api/organizations/${orgId}`, { + const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, { method: "PATCH", headers: { "Content-Type": "application/json", @@ -189,7 +190,7 @@ export function OrganizationList({
{org.name} @@ -264,7 +265,7 @@ export function OrganizationList({
{org.name} diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index 4d366ee..a2caadc 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -50,6 +50,7 @@ import AddRepositoryDialog from "./AddRepositoryDialog"; import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useNavigation } from "@/components/layout/MainLayout"; +import { withBase } from "@/lib/base-path"; const REPOSITORY_SORT_OPTIONS = [ { value: "imported-desc", label: "Recently Imported" }, @@ -1518,7 +1519,7 @@ export default function Repository() {