diff --git a/.env.example b/.env.example index ced130a..7cf7cca 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ SQLITE_PATH=./data/homelab.db # Set this to the URL(s) you use to access Homelable in your browser. CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] +# Serve the UI under a subpath instead of the root of the origin, e.g. /homelab/. +# Read by docker-compose at *build* time (docker compose build frontend), since the +# prefix is baked into the frontend bundle. See INSTALLATION.md. +# VITE_BASE_PATH=/homelab/ + # Auth — local mode is the backward-compatible default. AUTH_MODE=local # Local credentials: admin / admin by default. diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 745c359..5e8db73 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -7,6 +7,12 @@ FROM --platform=$BUILDPLATFORM node:20-slim AS builder ARG VITE_STANDALONE=false ENV VITE_STANDALONE=$VITE_STANDALONE +# Subpath support: build with VITE_BASE_PATH=/homelab/ to serve Homelable under a +# prefix instead of the root of an origin. The default '/' produces the same build +# as before the option existed. +ARG VITE_BASE_PATH=/ +ENV VITE_BASE_PATH=$VITE_BASE_PATH + WORKDIR /app COPY frontend/package*.json ./ RUN npm ci @@ -18,15 +24,31 @@ RUN npm run build # Stage 2: serve FROM nginx:alpine -COPY --from=builder /app/dist /usr/share/nginx/html +COPY --from=builder /app/dist /tmp/dist -# Nginx config: use standalone config (no backend proxy) or full config +# Nginx config: standalone (no backend proxy) or full, served at the root or under the +# VITE_BASE_PATH prefix. At the root the build lands in /usr/share/nginx/html and the +# shipped .conf is copied verbatim, so an existing deployment gets exactly what it had +# before this option existed. Under a prefix the build lands in the matching +# subdirectory, which keeps the generated config on plain `root` semantics. ARG VITE_STANDALONE=false -COPY docker/nginx.conf /etc/nginx/conf.d/default.conf -COPY docker/nginx.standalone.conf /etc/nginx/conf.d/nginx.standalone.conf -RUN if [ "$VITE_STANDALONE" = "true" ]; then \ - cp /etc/nginx/conf.d/nginx.standalone.conf /etc/nginx/conf.d/default.conf; \ - fi && \ - rm /etc/nginx/conf.d/nginx.standalone.conf +ARG VITE_BASE_PATH=/ +COPY docker/nginx.conf docker/nginx.standalone.conf /tmp/nginx-conf/ +COPY docker/nginx.subpath.conf.template docker/nginx.standalone.subpath.conf.template /tmp/nginx-conf/ +# ash with pipefail: the base-path normalization below pipes printf into sed. +SHELL ["/bin/ash", "-eo", "pipefail", "-c"] +RUN set -eu; \ + if [ "$VITE_STANDALONE" = "true" ]; then variant=nginx.standalone; else variant=nginx; fi; \ + base=$(printf '%s' "${VITE_BASE_PATH:-/}" | sed -e 's#^/*#/#' -e 's#/*$#/#' -e 's#//*#/#g'); \ + [ -n "$base" ] || base=/; \ + mkdir -p "/usr/share/nginx/html${base}"; \ + cp -a /tmp/dist/. "/usr/share/nginx/html${base}"; \ + if [ "$base" = "/" ]; then \ + cp "/tmp/nginx-conf/${variant}.conf" /etc/nginx/conf.d/default.conf; \ + else \ + sed -e "s#__BASE_NO_SLASH__#${base%/}#g" -e "s#__BASE__#${base}#g" \ + "/tmp/nginx-conf/${variant}.subpath.conf.template" > /etc/nginx/conf.d/default.conf; \ + fi; \ + rm -rf /tmp/dist /tmp/nginx-conf EXPOSE 80 diff --git a/INSTALLATION.md b/INSTALLATION.md index 573c757..ea2c5ec 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -189,6 +189,7 @@ sudo HTTP_PORT=8080 ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["10.0.0.0/24"]' \ | `ADMIN_PASSWORD` | prompt, else `admin` | Initial password for user `admin` | | `SCANNER_RANGES` | prompt, else guessed | JSON array of CIDRs | | `SKIP_NGINX=1` | off | Do not install or touch nginx | +| `BASE_PATH` | `/` | Serve under a subpath — see [Serving under a subpath](#serving-under-a-subpath) | ### Afterwards @@ -283,6 +284,83 @@ the service. --- +## Serving under a subpath + +By default Homelable owns the root of its origin (`https://homelable.example/`). +To put it behind an existing reverse proxy on a shared hostname — one cert, one +dynamic-DNS name, one open port, every service on its own prefix — build it with +a base path. + +The base path is **baked into the build**: the browser has no way to guess it, so +it cannot be a runtime setting. Changing it means rebuilding the frontend. + +### Docker + +```bash +# in .env, next to the backend settings +VITE_BASE_PATH=/homelab/ + +docker compose build frontend && docker compose up -d +``` + +Homelable then answers on `http://:3000/homelab/`. The generated nginx +config accepts both reverse-proxy styles, so either of these works in front of +it: + +```nginx +# prefix forwarded intact — no trailing slash on proxy_pass +location /homelab/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; +} + +# or: prefix stripped — trailing slash on proxy_pass +location /homelab/ { + proxy_pass http://127.0.0.1:3000/; + ... +} +``` + +The pre-built images (`docker-compose.prebuilt.yml`) are built for the root, so a +subpath needs a local build. + +### Bare metal / LXC + +```bash +sudo BASE_PATH=/homelab/ bash scripts/install-baremetal.sh +``` + +The script bakes the prefix into the build and writes the matching nginx site. +The build stays in `/opt/homelable/frontend/dist`; the site serves it through +`/var/www/homelable/homelab`, a symlink refreshed on every run. Re-running the +script with a different `BASE_PATH` (or none) rewrites both. + +### Development + +```bash +cd frontend && VITE_BASE_PATH=/homelab/ npm run dev # http://localhost:5173/homelab/ +``` + +The Vite dev proxy follows the same prefix and strips it before forwarding to +uvicorn on `:8000`. + +### What to expect + +- WebSocket status updates, uploaded floor plans and the read-only live view + (`/homelab/view`) all follow the prefix. +- TLS in front still means adding your hostname to `CORS_ORIGINS` in + `backend/.env`, exactly as at the root. +- OIDC: `OIDC_REDIRECT_URI` must carry the prefix + (`https://home.example/homelab/api/v1/auth/oidc/callback`), and so must the + redirect URI registered with your provider. +- Floor plans uploaded before the move keep working — stored URLs are resolved + against the base path at render time. + +--- + ## Configuration All configuration is done via `.env` (copied from `.env.example`): diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 6f091c6..527088f 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from starlette.responses import RedirectResponse from app.api.deps import AuthContext, get_auth_context -from app.core.config import settings +from app.core.config import app_base_path_of, settings from app.core.security import ( clear_oidc_session_cookie, create_access_token, @@ -103,7 +103,10 @@ async def oidc_callback(request: Request) -> Response: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None request.session.clear() - response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) + # Back to the SPA — which is not necessarily at the root of the origin. + response = RedirectResponse( + url=app_base_path_of(settings.oidc_redirect_uri), status_code=status.HTTP_303_SEE_OTHER + ) set_oidc_session_cookie(response, session_token) return response diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a66280c..ddb5f47 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -17,6 +17,26 @@ def origin_of(url: str) -> str: return "" return f"{parts.scheme}://{parts.netloc}" + +OIDC_CALLBACK_PATH = "/api/v1/auth/oidc/callback" + + +def app_base_path_of(redirect_uri: str) -> str: + """ + Path the SPA is served under, read back from OIDC_REDIRECT_URI. + + The frontend can be mounted under a prefix (`VITE_BASE_PATH=/homelab/`), and the + backend only ever learns about it through the callback URL the operator + configured — `https://home.example/homelab/api/v1/auth/oidc/callback` means the + app lives at `/homelab/`. Always returns a path with a trailing slash; `/` when + the URI is unset, relative, or not a callback URL. + """ + path = urlsplit(redirect_uri).path + if not path.endswith(OIDC_CALLBACK_PATH): + return "/" + base = path[: -len(OIDC_CALLBACK_PATH)] + return f"{base}/" if base else "/" + def _read_version() -> str: for candidate in [ Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 6571112..2e86a04 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -272,6 +272,40 @@ async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_s assert protected.status_code == 200 +async def test_oidc_callback_returns_to_the_app_base_path(client: AsyncClient, oidc_settings): + """The SPA can be mounted under a prefix — the post-login redirect follows it.""" + oidc_settings.oidc_redirect_uri = "http://test/homelab/api/v1/auth/oidc/callback" + fake_client = FakeOIDCClient(token={ + "userinfo": { + "iss": "https://idp.example/application/o/homelable/", + "sub": "user-123", + "preferred_username": "alice", + }, + }) + with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client): + callback = await client.get("/api/v1/auth/oidc/callback") + + assert callback.status_code == 303 + assert callback.headers["location"] == "/homelab/" + + +@pytest.mark.parametrize( + ("redirect_uri", "expected"), + [ + ("", "/"), + ("https://home.example/api/v1/auth/oidc/callback", "/"), + ("https://home.example/homelab/api/v1/auth/oidc/callback", "/homelab/"), + ("https://home.example/apps/lab/api/v1/auth/oidc/callback", "/apps/lab/"), + ("/api/v1/auth/oidc/callback", "/"), + ("https://home.example/somewhere/else", "/"), + ], +) +def test_app_base_path_of(redirect_uri: str, expected: str): + from app.core.config import app_base_path_of + + assert app_base_path_of(redirect_uri) == expected + + @pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}]) async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo): token = {} if userinfo is None else {"userinfo": userinfo} diff --git a/docker-compose.yml b/docker-compose.yml index ffa434a..11456c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,10 @@ services: build: context: . dockerfile: Dockerfile.frontend + args: + # Serve Homelable under a subpath, e.g. VITE_BASE_PATH=/homelab/ in .env. + # Defaults to the root — see docs/INSTALLATION.md. + VITE_BASE_PATH: ${VITE_BASE_PATH:-/} restart: unless-stopped ports: - "3000:80" diff --git a/docker/nginx.standalone.subpath.conf.template b/docker/nginx.standalone.subpath.conf.template new file mode 100644 index 0000000..dcce6da --- /dev/null +++ b/docker/nginx.standalone.subpath.conf.template @@ -0,0 +1,26 @@ +# Standalone (no backend) Homelable served under a subpath (__BASE__). +# Generated at image build time — see docker/nginx.subpath.conf.template. +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + # Relative Location headers — the port and scheme belong to whatever proxy is + # in front, not to this server block. + absolute_redirect off; + + location = __BASE_NO_SLASH__ { + return 301 __BASE__; + } + + # SPA fallback under the prefix — no backend proxy + location __BASE__ { + try_files $uri $uri/ __BASE__index.html; + } + + # Same, for a front proxy that strips the prefix before forwarding + location / { + root /usr/share/nginx/html__BASE_NO_SLASH__; + try_files $uri $uri/ /index.html; + } +} diff --git a/docker/nginx.subpath.conf.template b/docker/nginx.subpath.conf.template new file mode 100644 index 0000000..c79548c --- /dev/null +++ b/docker/nginx.subpath.conf.template @@ -0,0 +1,86 @@ +# Homelable served under a subpath (__BASE__) instead of the root of the origin. +# +# Generated at image build time by Dockerfile.frontend when VITE_BASE_PATH is set: +# __BASE__ is the prefix with its trailing slash, __BASE_NO_SLASH__ without it. A +# default build keeps docker/nginx.conf and this file is never read. +# +# The static build is copied to /usr/share/nginx/html__BASE__, so every block below +# uses plain `root` semantics — `alias` plus `try_files` mis-resolves $uri. +# +# Both reverse-proxy styles work against this config: +# - prefix forwarded intact (proxy_pass http://homelable;) -> the __BASE__ blocks +# - prefix stripped (proxy_pass http://homelable/;) -> the trailing blocks +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + # Relative Location headers — the port and scheme belong to whatever proxy is + # in front, not to this server block. + absolute_redirect off; + + # --- prefix forwarded intact ------------------------------------------------ + location = __BASE_NO_SLASH__ { + return 301 __BASE__; + } + + # WebSocket (must come before the API block to take priority) + location __BASE__api/v1/status/ws/ { + proxy_pass http://backend:8000/api/v1/status/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location __BASE__api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Legacy /ws/ path + location __BASE__ws/ { + proxy_pass http://backend:8000/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + # SPA fallback under the prefix + location __BASE__ { + try_files $uri $uri/ __BASE__index.html; + } + + # --- prefix already stripped by the front proxy ----------------------------- + # No redirect to __BASE__ here: the front proxy would strip it again and loop. + location /api/v1/status/ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + location / { + root /usr/share/nginx/html__BASE_NO_SLASH__; + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5992b73..20a4def 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { copyToClipboard } from '@/utils/clipboard' import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl' +import { withBase } from '@/utils/basePath' import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' @@ -772,7 +773,9 @@ export default function App() { if (STANDALONE) { // Standalone reads canvas from localStorage; pass the active design id so // the read-only tab renders the same canvas the user is viewing. - const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view' + const url = activeDesignId + ? withBase(`view?design=${encodeURIComponent(activeDesignId)}`) + : withBase('view') window.open(url, '_blank', 'noopener,noreferrer') return } @@ -784,7 +787,7 @@ export default function App() { } const params = new URLSearchParams({ key: res.data.key }) if (activeDesignId) params.set('design', activeDesignId) - window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer') + window.open(withBase(`view?${params.toString()}`), '_blank', 'noopener,noreferrer') } catch { toast.error('Failed to open live view') } diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts index 98f462c..dce52e8 100644 --- a/frontend/src/api/__tests__/client.test.ts +++ b/frontend/src/api/__tests__/client.test.ts @@ -70,6 +70,19 @@ describe('api/client', () => { expect(publicApi.defaults.baseURL).toBe('/api/v1') }) + it('mounts both instances under a configured base path', async () => { + const before = instances.length + vi.stubEnv('BASE_URL', '/homelab/') + vi.resetModules() + await import('../client') + const [subApi, subPublicApi] = instances.slice(before) + expect(subApi.defaults.baseURL).toBe('/homelab/api/v1') + expect(subApi.defaults.withCredentials).toBe(true) + expect(subPublicApi.defaults.baseURL).toBe('/homelab/api/v1') + vi.unstubAllEnvs() + vi.resetModules() + }) + it('exports `api` matching the first created instance', () => { expect(mod.api).toBe(api) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4adb0d7..9da093f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,15 +1,16 @@ import axios from 'axios' +import { API_BASE_URL } from '@/utils/basePath' import { useAuthStore } from '@/stores/authStore' import type { AuthMode, AuthUser } from '@/stores/authStore' import type { InventoryEntry } from '@/types' export const api = axios.create({ - baseURL: '/api/v1', + baseURL: API_BASE_URL, withCredentials: true, }) // Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints) -const publicApi = axios.create({ baseURL: '/api/v1' }) +const publicApi = axios.create({ baseURL: API_BASE_URL }) api.interceptors.request.use((config) => { const { token, csrfToken } = useAuthStore.getState() diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx index dddcb8e..aaeb8dd 100644 --- a/frontend/src/components/LoginPage.tsx +++ b/frontend/src/components/LoginPage.tsx @@ -7,6 +7,7 @@ import { Logo } from '@/components/ui/Logo' import { authApi } from '@/api/client' import { useAuthStore } from '@/stores/authStore' import { cn } from '@/lib/utils' +import { API_BASE_URL, resolveServerPath } from '@/utils/basePath' export function LoginPage() { const [username, setUsername] = useState('') @@ -111,7 +112,7 @@ export function LoginPage() { Continue through the configured OpenID Connect provider.

{ e.stopPropagation(); requestFloorMapEdit() }} > Floor plan { expect(container.querySelector('img')).toBeNull() }) + it('renders the stored image URL as-is at the root, base64 plans included', () => { + setFloorMap() + const { rerender } = render() + expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('/api/v1/media/abc.png') + + act(() => setFloorMap({ imageData: 'data:image/png;base64,abc' })) + rerender() + expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('data:image/png;base64,abc') + }) + it('hides resize handles until the plan is selected, then shows them (unlocked)', () => { setFloorMap() render() diff --git a/frontend/src/components/modals/DesignModal.tsx b/frontend/src/components/modals/DesignModal.tsx index 028db1c..71a8647 100644 --- a/frontend/src/components/modals/DesignModal.tsx +++ b/frontend/src/components/modals/DesignModal.tsx @@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons' import type { Design, DesignType, FloorMapConfig } from '@/types' +import { resolveServerPath } from '@/utils/basePath' /** Canvas kinds offered on create. `electrical` shares the network renderer. */ const CANVAS_KINDS: { value: DesignType; label: string; hint: string }[] = [ @@ -294,7 +295,7 @@ export function DesignModal({ ) : ( <>
- Floor plan preview + Floor plan preview
diff --git a/frontend/src/hooks/__tests__/useStatusPolling.basePath.test.ts b/frontend/src/hooks/__tests__/useStatusPolling.basePath.test.ts new file mode 100644 index 0000000..86a6cc4 --- /dev/null +++ b/frontend/src/hooks/__tests__/useStatusPolling.basePath.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useStatusPolling } from '../useStatusPolling' + +// Served under /homelab/ — the WebSocket URL has to carry the prefix too, or it +// lands on whatever else owns the root of the origin. +vi.mock('@/utils/basePath', () => ({ API_BASE_URL: '/homelab/api/v1' })) + +vi.mock('@/stores/canvasStore', () => ({ + useCanvasStore: () => ({ + setNodeStatus: vi.fn(), + notifyScanDeviceFound: vi.fn(), + setServiceStatuses: vi.fn(), + }), +})) + +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => ({ isAuthenticated: true, authMethod: 'local', token: 'test-token' }), +})) + +class MockWebSocket { + static instances: MockWebSocket[] = [] + url: string + onopen: (() => void) | null = null + onmessage: ((e: { data: string }) => void) | null = null + onerror: ((e: unknown) => void) | null = null + send = vi.fn() + close = vi.fn() + + constructor(url: string) { + this.url = url + MockWebSocket.instances.push(this) + } +} + +describe('useStatusPolling under a base path', () => { + beforeEach(() => { + MockWebSocket.instances = [] + vi.stubGlobal('WebSocket', MockWebSocket) + Object.defineProperty(window, 'location', { + value: { protocol: 'http:', host: 'home.example' }, + writable: true, + }) + }) + + afterEach(() => vi.restoreAllMocks()) + + it('prefixes the WebSocket URL with the base path', () => { + renderHook(() => useStatusPolling()) + expect(MockWebSocket.instances).toHaveLength(1) + expect(MockWebSocket.instances[0].url).toBe('ws://home.example/homelab/api/v1/status/ws/status') + }) +}) diff --git a/frontend/src/hooks/useStatusPolling.ts b/frontend/src/hooks/useStatusPolling.ts index f08c8dd..554c598 100644 --- a/frontend/src/hooks/useStatusPolling.ts +++ b/frontend/src/hooks/useStatusPolling.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' import type { ServiceStatus } from '@/types' +import { API_BASE_URL } from '@/utils/basePath' interface ServiceStatusEntry { port?: number @@ -58,7 +59,7 @@ export function useStatusPolling() { const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' const host = window.location.host // includes port when non-standard - const url = `${protocol}://${host}/api/v1/status/ws/status` + const url = `${protocol}://${host}${API_BASE_URL}/status/ws/status` const ws = new WebSocket(url) wsRef.current = ws diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ad53488..5e6b74e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,8 +3,9 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import LiveView from './components/LiveView.tsx' +import { isLiveViewPath } from './utils/basePath.ts' -const isLiveView = window.location.pathname === '/view' +const isLiveView = isLiveViewPath(window.location.pathname) createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/utils/__tests__/basePath.test.ts b/frontend/src/utils/__tests__/basePath.test.ts new file mode 100644 index 0000000..3dfdc68 --- /dev/null +++ b/frontend/src/utils/__tests__/basePath.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + BASE_PATH, + API_BASE_URL, + normalizeBasePath, + withBase, + resolveServerPath, + isLiveViewPath, +} from '@/utils/basePath' + +const SUB = '/homelab/' + +describe('normalizeBasePath', () => { + it('falls back to the root for an empty or missing value', () => { + expect(normalizeBasePath(undefined)).toBe('/') + expect(normalizeBasePath(null)).toBe('/') + expect(normalizeBasePath('')).toBe('/') + expect(normalizeBasePath(' ')).toBe('/') + expect(normalizeBasePath('/')).toBe('/') + }) + + it('adds the leading and trailing slash', () => { + expect(normalizeBasePath('homelab')).toBe('/homelab/') + expect(normalizeBasePath('/homelab')).toBe('/homelab/') + expect(normalizeBasePath('homelab/')).toBe('/homelab/') + expect(normalizeBasePath('/homelab/')).toBe('/homelab/') + }) + + it('keeps nested prefixes and collapses duplicate slashes', () => { + expect(normalizeBasePath('/apps/homelab')).toBe('/apps/homelab/') + expect(normalizeBasePath('//apps//homelab//')).toBe('/apps/homelab/') + }) +}) + +describe('default (root) base', () => { + it('is the root, so every helper returns the pre-base-path string', () => { + expect(BASE_PATH).toBe('/') + expect(API_BASE_URL).toBe('/api/v1') + expect(withBase('view')).toBe('/view') + expect(withBase('brand/homelable.svg')).toBe('/brand/homelable.svg') + expect(isLiveViewPath('/view')).toBe(true) + expect(isLiveViewPath('/')).toBe(false) + expect(resolveServerPath('/api/v1/media/abc.png')).toBe('/api/v1/media/abc.png') + }) +}) + +describe('withBase', () => { + it('joins onto the base with exactly one slash', () => { + expect(withBase('view', SUB)).toBe('/homelab/view') + expect(withBase('/view', SUB)).toBe('/homelab/view') + expect(withBase('//view', SUB)).toBe('/homelab/view') + expect(withBase('', SUB)).toBe('/homelab/') + }) + + it('keeps the query string intact', () => { + expect(withBase('view?key=abc&design=1', SUB)).toBe('/homelab/view?key=abc&design=1') + }) +}) + +describe('resolveServerPath', () => { + it('prefixes root-absolute paths the backend returns', () => { + expect(resolveServerPath('/api/v1/media/abc.png', SUB)).toBe('/homelab/api/v1/media/abc.png') + expect(resolveServerPath('/api/v1/auth/oidc/login', SUB)).toBe('/homelab/api/v1/auth/oidc/login') + }) + + it('leaves an already-prefixed path alone', () => { + expect(resolveServerPath('/homelab/api/v1/media/abc.png', SUB)).toBe('/homelab/api/v1/media/abc.png') + expect(resolveServerPath('/homelab', SUB)).toBe('/homelab') + }) + + it('leaves anything that is not a root-absolute path alone', () => { + expect(resolveServerPath('data:image/png;base64,AAAA', SUB)).toBe('data:image/png;base64,AAAA') + expect(resolveServerPath('blob:http://x/y', SUB)).toBe('blob:http://x/y') + expect(resolveServerPath('https://cdn.example/x.svg', SUB)).toBe('https://cdn.example/x.svg') + expect(resolveServerPath('//cdn.example/x.svg', SUB)).toBe('//cdn.example/x.svg') + expect(resolveServerPath('relative/x.svg', SUB)).toBe('relative/x.svg') + expect(resolveServerPath('', SUB)).toBe('') + }) +}) + +describe('isLiveViewPath', () => { + it('matches the live view under the base only', () => { + expect(isLiveViewPath('/homelab/view', SUB)).toBe(true) + expect(isLiveViewPath('/view', SUB)).toBe(false) + expect(isLiveViewPath('/homelab/', SUB)).toBe(false) + expect(isLiveViewPath('/homelab/viewer', SUB)).toBe(false) + }) +}) + +describe('module wiring', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('derives BASE_PATH and API_BASE_URL from import.meta.env.BASE_URL', async () => { + vi.stubEnv('BASE_URL', '/homelab/') + vi.resetModules() + const mod = await import('@/utils/basePath') + expect(mod.BASE_PATH).toBe('/homelab/') + expect(mod.API_BASE_URL).toBe('/homelab/api/v1') + expect(mod.withBase('view')).toBe('/homelab/view') + expect(mod.isLiveViewPath('/homelab/view')).toBe(true) + expect(mod.resolveServerPath('/api/v1/media/a.png')).toBe('/homelab/api/v1/media/a.png') + }) +}) diff --git a/frontend/src/utils/__tests__/brandIcons.test.ts b/frontend/src/utils/__tests__/brandIcons.test.ts index c1a210d..20f8cc4 100644 --- a/frontend/src/utils/__tests__/brandIcons.test.ts +++ b/frontend/src/utils/__tests__/brandIcons.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { BRAND_ICON_PREFIX, isBrandIconKey, @@ -34,6 +34,16 @@ describe('brand icon helpers', () => { expect(brandIconUrl('homelable')).toBe('/brand/homelable.svg') }) + it('serves the local icon from under a configured base path', async () => { + vi.stubEnv('BASE_URL', '/homelab/') + vi.resetModules() + const { brandIconUrl: scoped } = await import('@/utils/nodeIcons') + expect(scoped('homelable')).toBe('/homelab/brand/homelable.svg') + expect(scoped('plex')).toBe('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg') + vi.unstubAllEnvs() + vi.resetModules() + }) + it('isLocalBrandIcon only matches the locally-served slugs', () => { expect(LOCAL_BRAND_ICONS).toContain('homelable') expect(isLocalBrandIcon('homelable')).toBe(true) diff --git a/frontend/src/utils/basePath.ts b/frontend/src/utils/basePath.ts new file mode 100644 index 0000000..51084bf --- /dev/null +++ b/frontend/src/utils/basePath.ts @@ -0,0 +1,52 @@ +/** + * Base path — Homelable can be served under a subpath (`https://home.example/homelab/`) + * instead of the root of an origin. + * + * The single knob is the build-time `VITE_BASE_PATH` env var, which becomes Vite's + * `base` (see `vite.config.ts`). Vite derives `import.meta.env.BASE_URL` from it and + * rewrites the asset URLs it emits itself; everything the app builds by hand — API + * calls, the WebSocket URL, the live-view route, `public/` assets — goes through the + * helpers below. + * + * The default is `/`, where every helper returns exactly the string it returned before + * the base path existed. Only path bases are supported (not a full CDN URL). + */ + +/** Normalize a raw base into a `/`-delimited path that always ends in `/`. */ +export function normalizeBasePath(raw?: string | null): string { + const value = (raw ?? '').trim() + if (!value || value === '/') return '/' + return `/${value}/`.replace(/\/{2,}/g, '/') +} + +/** The active base path. Always starts and ends with `/`; `/` when unset. */ +export const BASE_PATH = normalizeBasePath(import.meta.env.BASE_URL) + +/** Join a path onto the base: `withBase('view')` → `/homelab/view`. */ +export function withBase(path: string, base: string = BASE_PATH): string { + return `${base}${path.replace(/^\/+/, '')}` +} + +/** Base URL of the REST API — what the axios instances are created with. */ +export const API_BASE_URL = withBase('api/v1') + +/** + * Resolve a root-absolute path the *backend* handed us (an uploaded media URL, the + * OIDC login URL) against the base path. The backend has no idea where the SPA is + * mounted, so it always answers `/api/v1/...`. + * + * Anything else — a `data:` URL, an absolute `http(s)://` URL, a protocol-relative + * `//host/...` one, a relative path, or a path already carrying the prefix — is + * returned untouched. + */ +export function resolveServerPath(url: string, base: string = BASE_PATH): string { + if (!url || base === '/') return url + if (!url.startsWith('/') || url.startsWith('//')) return url + if (url === base.slice(0, -1) || url.startsWith(base)) return url + return `${base}${url.slice(1)}` +} + +/** Is this pathname the read-only live view (`/view`, `/homelab/view`)? */ +export function isLiveViewPath(pathname: string, base: string = BASE_PATH): boolean { + return pathname === withBase('view', base) +} diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index b0dda8a..629a77e 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -1,4 +1,5 @@ import type { NodeType } from '@/types' +import { withBase } from '@/utils/basePath' import { // Infrastructure (node types) Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, @@ -236,7 +237,7 @@ export function isLocalBrandIcon(slug: string): boolean { } export function brandIconUrl(slug: string): string { - if (isLocalBrandIcon(slug)) return `/brand/${slug}.svg` + if (isLocalBrandIcon(slug)) return withBase(`brand/${slug}.svg`) return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg` } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7691592..36c321d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,7 +6,16 @@ import tailwindcss from '@tailwindcss/vite' const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim() +// Serve Homelable under a subpath (`VITE_BASE_PATH=/homelab/`) instead of the root of +// an origin. Defaults to '/', where the built output is byte-for-byte what it was +// before this knob existed. Mirrors `normalizeBasePath` in src/utils/basePath.ts — +// duplicated rather than imported because that module reads `import.meta.env`, which +// does not exist while this config is being evaluated by Node. +const rawBase = (process.env.VITE_BASE_PATH ?? '').trim() +const base = !rawBase || rawBase === '/' ? '/' : `/${rawBase}/`.replace(/\/{2,}/g, '/') + export default defineConfig({ + base, define: { __APP_VERSION__: JSON.stringify(appVersion), }, @@ -27,15 +36,19 @@ export default defineConfig({ }, }, server: { + // Keyed off the base so `npm run dev` keeps working when one is set: the app then + // calls //api/v1, and the prefix is stripped before hitting uvicorn. proxy: { - '/api': { + [`${base}api`]: { target: 'http://localhost:8000', changeOrigin: true, ws: true, + rewrite: (p: string) => p.replace(base, '/'), }, - '/ws': { + [`${base}ws`]: { target: 'ws://localhost:8000', ws: true, + rewrite: (p: string) => p.replace(base, '/'), }, }, }, diff --git a/scripts/install-baremetal.sh b/scripts/install-baremetal.sh index 5a85c18..cacf011 100755 --- a/scripts/install-baremetal.sh +++ b/scripts/install-baremetal.sh @@ -19,6 +19,9 @@ # BACKEND_PORT uvicorn listen port, loopback only (default: 8000) # HTTP_PORT nginx listen port (default: 3000) # SERVER_NAME nginx server_name (default: _) +# BASE_PATH serve under a subpath instead of the root of the origin, +# e.g. BASE_PATH=/homelab/ (default: /). Baked into the +# frontend build and into the generated nginx site. # ADMIN_PASSWORD initial admin password (default: prompt, "admin" on empty) # SCANNER_RANGES JSON array of CIDRs to scan (default: prompt, guessed from # the primary interface) @@ -37,6 +40,11 @@ BACKEND_PORT="${BACKEND_PORT:-8000}" HTTP_PORT="${HTTP_PORT:-3000}" SERVER_NAME="${SERVER_NAME:-_}" SKIP_NGINX="${SKIP_NGINX:-0}" +# Normalized to a leading + trailing slash ('/' when unset), mirroring +# normalizeBasePath() in frontend/src/utils/basePath.ts. +BASE_PATH="$(printf '%s' "${BASE_PATH:-/}" | sed -e 's#^/*#/#' -e 's#/*$#/#' -e 's#//*#/#g')" +[[ -n "$BASE_PATH" ]] || BASE_PATH="/" +BASE_PATH_NO_SLASH="${BASE_PATH%/}" NODE_MAJOR=20 log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } @@ -180,7 +188,7 @@ if [[ -f "$FRONTEND_DIR/package-lock.json" ]]; then else ( cd "$FRONTEND_DIR" && npm install --silent ) fi -( cd "$FRONTEND_DIR" && npm run build ) +( cd "$FRONTEND_DIR" && VITE_BASE_PATH="$BASE_PATH" npm run build ) [[ -d "$FRONTEND_DIR/dist" ]] || fail "Frontend build produced no $FRONTEND_DIR/dist." chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR" @@ -222,7 +230,8 @@ systemctl restart "$SERVICE_NAME" if [[ "$SKIP_NGINX" != "1" ]]; then SITE="/etc/nginx/sites-available/homelable" log "Writing $SITE" - cat >"$SITE" <"$SITE" <"$SITE" <}:${HTTP_PORT} + Web UI: http://${HOST_IP:-}:${HTTP_PORT}${BASE_PATH} nginx site: /etc/nginx/sites-available/homelable EOF + if [[ "$BASE_PATH" != "/" ]]; then + cat <