diff --git a/.env.example b/.env.example index 445ae37a7..374dc5e1a 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,12 @@ WEB_CONTAINER_NAME=gitnexus-web SERVER_HOST_PORT=4747 WEB_HOST_PORT=4173 +# Where the browser reaches the API. Unset: http://localhost:4747. An absolute +# URL for a server on another host; `/` behind a reverse proxy that serves the +# API on the same origin as the UI (see README, "Same-origin API behind a +# reverse proxy"). +# GITNEXUS_BACKEND_URL=/ + # Optional read-only mount, exposed to the server as /workspace. # Override with the directory that contains the repos you want to index. WORKSPACE_DIR=./ diff --git a/docker-compose.yaml b/docker-compose.yaml index 3434c4416..f41e7bb91 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,6 +34,9 @@ services: # (http://localhost:4747) works when both containers run locally. # Set GITNEXUS_BACKEND_URL in your .env or shell for remote/custom setups: # GITNEXUS_BACKEND_URL=http://:4747 + # Behind a reverse proxy that routes /api/* next to the UI, set it to `/`: + # the browser then calls the API on whichever hostname served the page + # (see "Same-origin API behind a reverse proxy" in the README). environment: - GITNEXUS_BACKEND_URL=${GITNEXUS_BACKEND_URL:-} depends_on: diff --git a/docker-server.mjs b/docker-server.mjs index 3b036f7db..f1409d941 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -78,12 +78,26 @@ function numberFromEnv(label, fallback, min = 0) { return n; } +// A root-relative value — `/`, or `/prefix` when the API is mounted under a +// path — names no host at all: the browser resolves it against the origin that +// served the page, so one deployment answers on every hostname a reverse proxy +// gives it (#3593). Not a URL, so it is read before validHttpUrl would warn. +// `//host` is protocol-relative, an origin change, and is left to the URL check; +// so is `/\host`, since the URL parser reads a backslash as a slash. +function sameOriginPath(value) { + if (typeof value !== 'string' || !/^\/(?![/\\])/.test(value)) return null; + // A base for `${base}/api/...`: no query, fragment, backslash, whitespace or + // control char. + if (/[?#\\\s\x00-\x1f\x7f]/.test(value)) return null; + return value.replace(/\/+$/, '') || '/'; +} + // Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser // its own public origin — same-origin API calls via the proxy below, no config. const backendUrlVar = process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL'; const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null; -const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl); +const backendUrl = sameOriginPath(rawBackendUrl) ?? validHttpUrl(backendUrlVar, rawBackendUrl); const configScript = backendUrl ? `` : ''; diff --git a/docker-server.test.mjs b/docker-server.test.mjs index 309edf3b5..e07371992 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -199,6 +199,47 @@ it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => { }); }); +it('injects a same-origin path when GITNEXUS_BACKEND_URL is /', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: '/' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok(res.body.includes('"backendUrl":"/"'), 'Expected the root path as the backend URL'); + }); +}); + +it('injects a same-origin prefix without its trailing slash', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: '/gitnexus/' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + res.body.includes('"backendUrl":"/gitnexus"'), + 'Expected the prefix as the backend URL', + ); + }); +}); + +it('does not inject a protocol-relative GITNEXUS_BACKEND_URL', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: '//evil.example' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + !res.body.includes('__GITNEXUS_CONFIG__'), + 'Expected no __GITNEXUS_CONFIG__ for a protocol-relative value', + ); + }); +}); + +it('does not inject a backslash-prefixed GITNEXUS_BACKEND_URL, which a URL parser reads as a host', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: '/\\evil.example' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + !res.body.includes('__GITNEXUS_CONFIG__'), + 'Expected no __GITNEXUS_CONFIG__ for a backslash-prefixed value', + ); + }); +}); + it('does not inject when GITNEXUS_BACKEND_URL is not set', async () => { await withInjectionServer({}, async (port) => { const res = await rawGet(port, '/'); diff --git a/docs/deployment.md b/docs/deployment.md index 226d8a144..8893d6153 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -79,6 +79,29 @@ cp .env.example .env docker compose --env-file .env up -d ``` +
+Same-origin API behind a reverse proxy + +`GITNEXUS_BACKEND_URL` is normally an absolute URL, which ties the UI to one API hostname. Set it to `/` instead and the browser calls the API on the origin that served the page — so one `gitnexus-web` container can be reached on several hostnames (an internal `*.localhost` name and a public one, say) as long as `/api/*` is served next to the UI on each of them. A path such as `/gitnexus` works the same way for an API mounted under a prefix. + +`/api/*` goes through the web container's own proxy: give `gitnexus-web` `GITNEXUS_UPSTREAM_URL=http://gitnexus-server:4747` and a `GITNEXUS_SERVE_AUTH_TOKEN`; it forwards `/api/*` to the server and ends the browser origin there, so the server's CORS allowlist never sees the public hostnames. The UI asks for the token once. With Traefik, one router for the whole UI is enough: + +```yaml +services: + gitnexus-web: + environment: + - GITNEXUS_BACKEND_URL=/ + - GITNEXUS_UPSTREAM_URL=http://gitnexus-server:4747 + - GITNEXUS_SERVE_AUTH_TOKEN=${GITNEXUS_SERVE_AUTH_TOKEN} + labels: + - traefik.http.routers.gitnexus.rule=Host(`gitnexus.example.com`) || Host(`gitnexus.localhost`) + - traefik.http.services.gitnexus.loadbalancer.server.port=4173 +``` + +Routing `/api/*` straight to `gitnexus serve` on a public hostname is not supported: `serve` has no authentication of its own yet, and it refuses to start with `GITNEXUS_PUBLIC_ORIGIN` set until it does. Reach it through the proxy above. + +
+ Files: - [Dockerfile.web](../Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. diff --git a/gitnexus-web/src/config/ui-constants.ts b/gitnexus-web/src/config/ui-constants.ts index 6a5c0aad7..f8479e2cd 100644 --- a/gitnexus-web/src/config/ui-constants.ts +++ b/gitnexus-web/src/config/ui-constants.ts @@ -2,7 +2,30 @@ export const ERROR_RESET_DELAY_MS = 3000; export const BACKEND_URL_DEBOUNCE_MS = 500; +/** + * A root-relative injected backend (`/`, or `/prefix`) resolved against the + * page's own origin, so the API is reached on whichever hostname served the UI + * (#3593); undefined when nothing was injected or an absolute URL was. + * `//host` and `/\host` (the URL parser reads a backslash as a slash) are + * origin changes, not paths, and are left to the URL validation to reject. + */ +function sameOriginBackendUrl(): string | undefined { + if (typeof window === 'undefined') return undefined; + const configured = window.__GITNEXUS_CONFIG__?.backendUrl; + if (!configured || !/^\/(?![/\\])/.test(configured)) return undefined; + return new URL(configured, window.location.origin).href.replace(/\/+$/, ''); +} + +/** + * The backend base a same-origin deployment mounted the API under, kept as + * configured by normalizeServerUrl: a prefix such as `/proxy/api` names a + * mount point, not a pasted endpoint. Undefined for an absolute injected URL, + * which is normalized like any other. + */ +export const SAME_ORIGIN_BACKEND_URL = sameOriginBackendUrl(); + export const DEFAULT_BACKEND_URL = + SAME_ORIGIN_BACKEND_URL || (typeof window !== 'undefined' && window.__GITNEXUS_CONFIG__?.backendUrl) || 'http://localhost:4747'; export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'; diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 3bddba929..2cc0d4885 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -9,6 +9,7 @@ import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared'; import { + SAME_ORIGIN_BACKEND_URL, AUTH_TOKEN_STORAGE_KEY, LARGE_GRAPH_NODE_THRESHOLD, LARGE_GRAPH_EDGE_THRESHOLD, @@ -451,6 +452,12 @@ function stripBackendUrlCredentials(url: string): string { export function normalizeServerUrl(input: string): string { let url = input.trim().replace(/\/+$/, ''); + // A same-origin deployment's base is kept as configured: a prefix such as + // `/proxy/api` names where the API is mounted, not a pasted endpoint, so the + // `/api` cleanup below is for hand-entered and absolute URLs only (#3593). + // That base is resolved from the page origin, so it carries no credentials. + if (SAME_ORIGIN_BACKEND_URL !== undefined && url === SAME_ORIGIN_BACKEND_URL) return url; + if (!url.startsWith('http://') && !url.startsWith('https://')) { if (url.startsWith('localhost') || url.startsWith('127.0.0.1')) { url = `http://${url}`; diff --git a/gitnexus-web/src/vite-env.d.ts b/gitnexus-web/src/vite-env.d.ts index cbbe32bf1..9666fafb1 100644 --- a/gitnexus-web/src/vite-env.d.ts +++ b/gitnexus-web/src/vite-env.d.ts @@ -2,6 +2,11 @@ interface Window { __GITNEXUS_CONFIG__?: { + /** + * Base URL of the API server, or a root-relative path (`/`, `/prefix`) + * meaning the origin that served the page. See DEFAULT_BACKEND_URL in + * config/ui-constants.ts and issue #3593. + */ backendUrl?: string; /** * Node-count above which the WebUI connects in chat-only mode by default diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts index fd76567eb..2ae8ceee6 100644 --- a/gitnexus-web/test/unit/server-connection.test.ts +++ b/gitnexus-web/test/unit/server-connection.test.ts @@ -375,6 +375,83 @@ describe('DEFAULT_BACKEND_URL resolution', () => { const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747'); }); + + it('resolves a root-relative backendUrl against the origin that served the page', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: '/' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe(window.location.origin); + }); + + it('keeps a same-origin prefix under the page origin', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: '/gitnexus' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe(`${window.location.origin}/gitnexus`); + }); + + it('does not resolve a protocol-relative backendUrl as a path', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: '//evil.example' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe('//evil.example'); + }); + + it('does not resolve a backslash-prefixed backendUrl, which the URL parser reads as a host', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: '/\\evil.example' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + expect(DEFAULT_BACKEND_URL).toBe('/\\evil.example'); + }); + + it('keeps a configured prefix ending in /api through health, discovery and repository selection', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: '/proxy/api' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + const client = await import('../../src/services/backend-client'); + const base = `${window.location.origin}/proxy/api`; + expect(DEFAULT_BACKEND_URL).toBe(base); + + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + const fetchMock = vi.fn((url: string) => { + if (url.includes('/api/repo?')) { + return Promise.resolve( + json({ name: 'big-repo', path: '/repos/big-repo', indexedAt: '2026-06-13T00:00:00Z' }), + ); + } + return Promise.resolve(json([])); + }); + vi.stubGlobal('fetch', fetchMock); + + client.setBackendUrl(DEFAULT_BACKEND_URL); + expect(await client.probeBackendStatus()).toBe('ok'); + expect(await client.fetchRepos()).toEqual([]); + const result = await client.connectToServer( + DEFAULT_BACKEND_URL, + undefined, + undefined, + 'big-repo', + { + skipGraph: true, + }, + ); + expect(result.repoInfo.name).toBe('big-repo'); + + const requested = fetchMock.mock.calls.map(([u]) => String(u)); + expect(requested).toEqual([ + `${base}/api/health`, + `${base}/api/repos`, + `${base}/api/repo?repo=big-repo`, + ]); + // A hand-entered endpoint URL still loses its /api suffix. + expect(client.normalizeServerUrl('http://localhost:4747/api')).toBe('http://localhost:4747'); + }); + + it('still cleans an absolute configured URL: a trailing /api and credentials', async () => { + window.__GITNEXUS_CONFIG__ = { backendUrl: 'http://user:secret@10.0.0.1:4747/api' }; + const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants'); + const client = await import('../../src/services/backend-client'); + expect(client.normalizeServerUrl(DEFAULT_BACKEND_URL)).toBe('http://10.0.0.1:4747'); + }); }); describe('LARGE_GRAPH_NODE_THRESHOLD resolution', () => {