diff --git a/docker-compose.yaml b/docker-compose.yaml index 7491ccdd3..20a5f19f4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,8 +33,14 @@ services: # Optional: override the backend URL served to the browser. # Required when the gitnexus-server is not reachable at http://localhost:4747 # from the user's browser (e.g. remote server deployments). - # Use http://host.docker.internal:4747 on Docker Desktop (Mac/Windows), - # or the server's LAN IP for Linux hosts. + # + # Docker Desktop (Mac/Windows): + # GITNEXUS_BACKEND_URL=http://host.docker.internal:4747 + # + # Linux / remote server: + # GITNEXUS_BACKEND_URL=http://:4747 + # (host.docker.internal requires extra_hosts on Linux Docker Engine) + # # environment: # - GITNEXUS_BACKEND_URL=http://host.docker.internal:4747 depends_on: diff --git a/docker-server.mjs b/docker-server.mjs index 6ba3ddfe5..84cd1dec3 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -16,15 +16,23 @@ function isValidUrl(value) { } } +function jsonForScriptTag(obj) { + return JSON.stringify(obj) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026'); +} + const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null; if (rawBackendUrl && !isValidUrl(rawBackendUrl)) { + const safeRaw = rawBackendUrl.replace(/[\r\n]/g, ' ').slice(0, 200); console.warn( - `[gitnexus-web] GITNEXUS_BACKEND_URL "${rawBackendUrl}" is not a valid http/https URL — ignoring.`, + `[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`, ); } const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null; const configScript = backendUrl - ? `` + ? `` : ''; const contentTypes = { diff --git a/docker-server.test.mjs b/docker-server.test.mjs index f1118d5c3..60cdcfb58 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -106,60 +106,128 @@ it('returns 404 when dist/index.html is missing', async () => { assert.equal(res.status, 404); }); -// ── Config injection logic tests (inline, independent of server process) ───── +// -- Config injection: server-level integration tests --- -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +function spawnServerWithEnv(cwd, port, env) { + const proc = spawn(process.execPath, [serverScript], { + cwd, + env: { ...process.env, PORT: String(port), ...env }, + stdio: 'pipe', + }); + proc.on('error', (err) => { + throw err; + }); + return proc; +} -function isValidUrl(value) { +async function withInjectionServer(envOverrides, fn) { + const dir = await mkdtemp(join(tmpdir(), 'gitnexus-inject-')); + const distDir = join(dir, 'dist'); + const assetsDir = join(distDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + await writeFile( + join(distDir, 'index.html'), + 'app', + ); + await writeFile(join(assetsDir, 'style.abc.css'), 'body{}'); + + const port = await getFreePort(); + const proc = spawnServerWithEnv(dir, port, envOverrides); try { - const u = new URL(value); - return u.protocol === 'http:' || u.protocol === 'https:'; - } catch { - return false; + await waitForServer(port); + await fn(port); + } finally { + proc.kill(); + await rm(dir, { recursive: true, force: true }); } } -function makeInjectedHtml(envBackendUrl) { - const rawHtml = ''; - const backendUrl = envBackendUrl && isValidUrl(envBackendUrl) ? envBackendUrl : null; - const configScript = backendUrl - ? `` - : ''; - return configScript ? rawHtml.replace('', `${configScript}`) : rawHtml; -} - -it('injects __GITNEXUS_CONFIG__ when GITNEXUS_BACKEND_URL is a valid URL', () => { - const html = makeInjectedHtml('http://10.0.0.1:4747'); - assert.ok( - html.includes( - '', - ), - 'Expected config script to be injected into index.html', - ); +it('injects __GITNEXUS_CONFIG__ into / when GITNEXUS_BACKEND_URL is valid', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + assert.ok( + res.body.includes('window.__GITNEXUS_CONFIG__'), + 'Expected __GITNEXUS_CONFIG__ in response body', + ); + assert.ok(res.body.includes('http://10.0.0.1:4747'), 'Expected backend URL in response body'); + }); }); -it('does not inject when GITNEXUS_BACKEND_URL is not set', () => { - const raw = ''; - const html = makeInjectedHtml(null); - assert.equal(html, raw, 'Expected index.html to be unchanged when no env var is set'); +it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => { + await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => { + const res = await rawGet(port, '/some/deep/link'); + assert.equal(res.status, 200); + assert.ok( + res.body.includes('window.__GITNEXUS_CONFIG__'), + 'Expected __GITNEXUS_CONFIG__ in SPA fallback response', + ); + assert.ok( + res.body.includes('http://10.0.0.1:4747'), + 'Expected backend URL in SPA fallback response', + ); + }); }); -it('injects config script before ', () => { - const html = makeInjectedHtml('http://10.0.0.1:4747'); - const headCloseIdx = html.indexOf(''); - const scriptIdx = html.indexOf(' in GITNEXUS_BACKEND_URL to prevent XSS', async () => { + const xssUrl = 'http://example.com/?x='; + await withInjectionServer({ GITNEXUS_BACKEND_URL: xssUrl }, async (port) => { + const res = await rawGet(port, '/'); + assert.equal(res.status, 200); + + const scriptMatches = res.body.match(/