diff --git a/backend/core/spa_inject.py b/backend/core/spa_inject.py new file mode 100644 index 00000000..c7ef3855 --- /dev/null +++ b/backend/core/spa_inject.py @@ -0,0 +1,35 @@ +"""Runtime API-base injection for the served SPA (Docker / reverse-proxy). + +`VITE_*` vars are inlined at build time, so a prebuilt image cannot take an +API-base override from `docker run -e`. When `OMNIVOICE_PUBLIC_API_BASE` is set, +the backend injects it into `index.html` as `window.__OMNIVOICE_API_BASE__`, +which the SPA's API resolver reads first. These helpers are pure so they can be +unit-tested without booting the app. +""" +from __future__ import annotations + +import json +import re + +# Operator-controlled value, but validate to a plain http(s) URL with no +# whitespace, quotes, or angle brackets so it can never break out of the +# injected " + if "" in html_doc: + return html_doc.replace("", "" + snippet, 1) + return snippet + html_doc diff --git a/backend/main.py b/backend/main.py index 7b225610..6845be79 100644 --- a/backend/main.py +++ b/backend/main.py @@ -635,6 +635,37 @@ app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist") if os.path.exists(frontend_path): + # ── Runtime API-base override (Docker / reverse-proxy deployments) ────── + # When OMNIVOICE_PUBLIC_API_BASE is set we inject it into index.html as + # `window.__OMNIVOICE_API_BASE__`, which the SPA's API resolver reads first. + # Unset (the default) → StaticFiles serves index.html untouched: same-origin, + # zero overhead, no behavior change. See core/spa_inject.py. + from core.spa_inject import is_valid_public_api_base, inject_api_base + + _public_api_base = os.environ.get("OMNIVOICE_PUBLIC_API_BASE", "").strip().rstrip("/") + _index_path = os.path.join(frontend_path, "index.html") + if _public_api_base and not is_valid_public_api_base(_public_api_base): + logging.getLogger("omnivoice.api").warning( + "OMNIVOICE_PUBLIC_API_BASE=%r is not a valid http(s) URL; ignoring.", + _public_api_base, + ) + _public_api_base = "" + + if _public_api_base and os.path.isfile(_index_path): + from fastapi.responses import HTMLResponse + + def _index_with_api_base() -> "HTMLResponse": + with open(_index_path, "r", encoding="utf-8") as _fh: + return HTMLResponse(inject_api_base(_fh.read(), _public_api_base)) + + @app.get("/", include_in_schema=False) + def _index_root(): + return _index_with_api_base() + + @app.get("/index.html", include_in_schema=False) + def _index_html(): + return _index_with_api_base() + app.mount("/", StaticFiles(directory=frontend_path, html=True), name="frontend") else: diff --git a/docs/install/docker.md b/docs/install/docker.md index aa4c6451..93180acb 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -63,18 +63,27 @@ services: - "0.0.0.0:3900:3900" # ← was 127.0.0.1:3900:3900 ``` -The OmniVoice frontend uses `window.location.host` for its API base when no -explicit override is set, so opening the UI from `http://:3900` Just -Works for both the page load *and* the media-preview requests it kicks off -afterwards. If you front the app with a reverse proxy and the API and UI -land on different origins, pin the API base explicitly: +The OmniVoice frontend defaults to the **same origin** the page was served +from, so opening the UI from `http://:3900` Just Works for both the +page load *and* the API/media requests it makes afterwards. + +If you front the app with a **reverse proxy** and the API and UI land on +different origins, pin the API base explicitly. Use **`OMNIVOICE_PUBLIC_API_BASE`** +— a *runtime* env var the backend injects into the page, so it works with the +prebuilt image via `docker run -e` (the older `VITE_OMNIVOICE_API` is inlined at +*build* time and cannot be set on a prebuilt image): ```bash -docker run -e VITE_OMNIVOICE_API=https://api.your-host.example \ +docker run -e OMNIVOICE_PUBLIC_API_BASE=https://api.your-host.example \ -p 0.0.0.0:3900:3900 \ ghcr.io/debpalash/omnivoice-studio:latest ``` +> `OMNIVOICE_PUBLIC_API_BASE` must be a plain `http(s)://…` URL; anything else +> is ignored and the app falls back to same-origin. If you build from source you +> may instead bake `VITE_OMNIVOICE_API` at build time, but the runtime var above +> is simpler and image-agnostic. + > **Security:** OmniVoice ships no authentication. Anything on your LAN with > the URL can use the app. Put it behind a reverse proxy with `basic_auth` > (Caddy / nginx + htpasswd) or a private network overlay (Tailscale, ZeroTier) diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index eeeaeb90..9d90ffc1 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -104,9 +104,11 @@ pane shows 404s for `/media/...`. **Cause:** pre-v0.3, the frontend hardcoded `localhost:3900` for media-preview URLs, which is wrong when the UI is reached from a different LAN host. -**Fix:** Plan 01-03 ships a fix that derives the media-preview base from -`window.location.host`. See [docker.md#lan-access](docker.md#lan-access) for the -override env var (`VITE_OMNIVOICE_API`) when running behind a reverse proxy. +**Fix:** the frontend derives its API/media base from the page's own origin. +When running behind a reverse proxy where the UI and API are on different +origins, set the runtime override `OMNIVOICE_PUBLIC_API_BASE` (works on the +prebuilt image via `docker run -e`) — see +[docker.md#lan-access](docker.md#lan-access). ## 9. Apple Silicon `mlx-whisper` unavailable on Intel mac diff --git a/frontend/src/api/client.apibase.test.ts b/frontend/src/api/client.apibase.test.ts index 80e3fb31..389d057f 100644 --- a/frontend/src/api/client.apibase.test.ts +++ b/frontend/src/api/client.apibase.test.ts @@ -32,4 +32,20 @@ describe('_resolveApiBase', () => { const win = { __TAURI__: {}, location: { origin: 'x', hostname: 'x' } }; expect(_resolveApiBase({ VITE_API_PORT: '4000' }, win)).toBe('http://127.0.0.1:4000'); }); + + it('runtime window.__OMNIVOICE_API_BASE__ wins over everything (Docker prebuilt-image override)', () => { + const win = { __TAURI__: {}, __OMNIVOICE_API_BASE__: 'https://api.example.com/', location: { origin: 'http://x', hostname: 'x' } }; + // Beats Tauri loopback AND VITE_API_URL; trailing slash stripped. + expect(_resolveApiBase({ VITE_API_URL: 'http://10.0.0.5:9' }, win)).toBe('https://api.example.com'); + }); + + it('honors VITE_OMNIVOICE_API (the documented Docker var) and strips trailing slash', () => { + const win = { location: { origin: 'http://x', hostname: 'x' } }; + expect(_resolveApiBase({ VITE_OMNIVOICE_API: 'http://10.0.0.5:9/' }, win)).toBe('http://10.0.0.5:9'); + }); + + it('Tauri via __TAURI_INTERNALS__ → loopback', () => { + const win = { __TAURI_INTERNALS__: {}, location: { origin: 'tauri://localhost', hostname: 'localhost' } }; + expect(_resolveApiBase({}, win)).toBe('http://127.0.0.1:3900'); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 01db2936..8c1a8ade 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -13,9 +13,18 @@ const viteEnv = import.meta.env ?? {}; // re-import the module or stub import.meta.env. export function _resolveApiBase(env: any, win: any): string { const port = env?.VITE_API_PORT || '3900'; - if (env?.VITE_API_URL) return env.VITE_API_URL; + // Explicit override, in precedence order: + // 1. window.__OMNIVOICE_API_BASE__ — RUNTIME global the backend injects + // into index.html from OMNIVOICE_PUBLIC_API_BASE. The only override that + // works on a prebuilt Docker image (VITE_* is inlined at build time). + // 2. VITE_OMNIVOICE_API — the build-time var documented for Docker/proxy + // deploys and used by utils/apiBase.ts. + // 3. VITE_API_URL — legacy alias. + const runtime = win && typeof win.__OMNIVOICE_API_BASE__ === 'string' ? win.__OMNIVOICE_API_BASE__ : ''; + const override = runtime || env?.VITE_OMNIVOICE_API || env?.VITE_API_URL; + if (override) return String(override).replace(/\/+$/, ''); if (!win) return `http://127.0.0.1:${port}`; - if (win.__TAURI__) return `http://127.0.0.1:${port}`; + if (win.__TAURI__ || win.__TAURI_INTERNALS__) return `http://127.0.0.1:${port}`; if (env?.DEV) return `http://${win.location.hostname}:${port}`; return win.location.origin; } diff --git a/frontend/src/utils/apiBase.test.ts b/frontend/src/utils/apiBase.test.ts index 7b040042..91c3ad35 100644 --- a/frontend/src/utils/apiBase.test.ts +++ b/frontend/src/utils/apiBase.test.ts @@ -24,6 +24,7 @@ describe("apiBase.getApiBase", () => { }); afterEach(() => { + delete (window as Window).__OMNIVOICE_API_BASE__; if (originalTauriInternals === undefined) { delete (window as Window).__TAURI_INTERNALS__; } else { @@ -64,6 +65,16 @@ describe("apiBase.getApiBase", () => { mod._setEnvOverrideForTesting(undefined); }); + it("runtime window.__OMNIVOICE_API_BASE__ wins over Tauri + env (Docker prebuilt-image override)", async () => { + (window as Window).__TAURI_INTERNALS__ = {}; + (window as Window).__OMNIVOICE_API_BASE__ = "https://api.example.com/"; + vi.resetModules(); + const mod = await import("./apiBase"); + mod._setEnvOverrideForTesting("http://10.0.0.5:3900"); + expect(mod.getApiBase()).toBe("https://api.example.com"); // trailing slash stripped + mod._setEnvOverrideForTesting(undefined); + }); + it("returns localhost:3900 in Tauri context", async () => { (window as Window).__TAURI_INTERNALS__ = {}; const { getApiBase } = await import("./apiBase"); diff --git a/frontend/src/utils/apiBase.ts b/frontend/src/utils/apiBase.ts index d8fb2ddb..aba7d013 100644 --- a/frontend/src/utils/apiBase.ts +++ b/frontend/src/utils/apiBase.ts @@ -27,6 +27,9 @@ declare global { interface Window { __TAURI_INTERNALS__?: unknown; __TAURI__?: unknown; + /** Runtime API base injected into index.html by the backend from + * OMNIVOICE_PUBLIC_API_BASE (Docker / reverse-proxy deployments). */ + __OMNIVOICE_API_BASE__?: string; } } @@ -63,7 +66,16 @@ function _readEnvOverride(): string | undefined { } export function getApiBase(): string { - // 1. Explicit override always wins. + // 0. Runtime override injected by the backend (Docker/proxy) wins over + // everything — it's the only knob a prebuilt image can turn at run time. + if (typeof window !== "undefined") { + const runtime = window.__OMNIVOICE_API_BASE__; + if (typeof runtime === "string" && runtime) { + return stripTrailingSlash(runtime); + } + } + + // 1. Explicit build-time override. const override = _readEnvOverride(); if (override) { return stripTrailingSlash(override); diff --git a/tests/test_spa_inject.py b/tests/test_spa_inject.py new file mode 100644 index 00000000..37bf53e3 --- /dev/null +++ b/tests/test_spa_inject.py @@ -0,0 +1,43 @@ +"""Runtime API-base injection helpers (Docker / reverse-proxy deployments). + +A prebuilt image can't take a build-time VITE_* override, so the backend +injects OMNIVOICE_PUBLIC_API_BASE into index.html as a window global. These +test the pure helpers without booting the app. +""" +from __future__ import annotations + +from core.spa_inject import inject_api_base, is_valid_public_api_base + + +def test_valid_public_api_base_accepts_http_urls(): + assert is_valid_public_api_base("https://api.example.com") + assert is_valid_public_api_base("http://10.0.0.5:3900") + assert is_valid_public_api_base("https://voice.example.com/api") + + +def test_valid_public_api_base_rejects_unsafe_or_empty(): + assert not is_valid_public_api_base("") + assert not is_valid_public_api_base("not a url") + assert not is_valid_public_api_base("javascript:alert(1)") + # No script breakout possible — angle brackets / quotes are rejected. + assert not is_valid_public_api_base('https://x"") + + +def test_inject_api_base_into_head(): + doc = "x" + out = inject_api_base(doc, "https://api.example.com") + assert '' in out + assert out.count("") == 1 # injected once, original head preserved + + +def test_inject_api_base_prepends_when_no_head(): + out = inject_api_base("x", "http://10.0.0.5:3900") + assert out.startswith('') + + +def test_inject_api_base_json_encodes_value(): + # json.dumps wraps in double quotes; combined with is_valid_public_api_base + # the value can't contain a quote, so the snippet is always well-formed. + out = inject_api_base("", "https://a/b") + assert '="https://a/b";' in out