feat(deploy): serve Homelable under a configurable base path
Homelable could only own the root of an origin. Behind an existing proxy at `https://home.example/homelab/` the built `index.html` still asked for `/assets/*`, which fell through to whatever owned the root — and when that answered `text/html` for a `<script>` under `nosniff`, the browser failed the load on an HTTP 200. The only workaround was patching the checkout and rebuilding on every upgrade. One build-time knob, `VITE_BASE_PATH`, becomes Vite's `base`. Vite rewrites the asset URLs it emits; everything the app builds by hand goes through the new `utils/basePath.ts` — the axios instances, the WebSocket URL, the live-view route, the local brand icons, and the OIDC login href. `resolveServerPath` covers what the *backend* hands back, which is always root-absolute because it cannot know where the SPA is mounted: uploaded floor-plan URLs already stored in a canvas are resolved at render time, so plans predating the move keep loading. The OIDC callback used to redirect to `/`, dropping subpath users at the origin root after login; it now reads the prefix back out of `OIDC_REDIRECT_URI`. The default is `/`, and stays a no-op there by construction: every helper returns the string it returned before, the root build output is unchanged and both nginx sites are byte-identical to what shipped — the Docker image copies `docker/nginx.conf` verbatim and the installer keeps its original heredoc. Only a non-default prefix takes the generated config. Those generated configs use `root`, never `alias`, since `alias` plus `try_files` mis-resolves `$uri` — the Docker build lands the bundle in the matching subdirectory, and the installer symlinks it under `/var/www/homelable`. Both accept either reverse-proxy style, prefix forwarded intact or already stripped, with no redirect loop between them, and `absolute_redirect off` stops the no-slash 301 from eating the port. Closes #334. ha-relevant: maybe
This commit is contained in:
committed by
Pouzor - Rémy Jardient
parent
3cbda60277
commit
4e8d841807
@@ -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.
|
||||
|
||||
+30
-8
@@ -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
|
||||
|
||||
@@ -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://<host>: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`):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
<a
|
||||
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
|
||||
href={resolveServerPath(oidcLoginUrl ?? `${API_BASE_URL}/auth/oidc/login`)}
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { resolveServerPath } from '@/utils/basePath'
|
||||
|
||||
interface ResizeState {
|
||||
startMouseX: number
|
||||
@@ -152,7 +153,7 @@ export function FloorMapLayer() {
|
||||
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
|
||||
>
|
||||
<img
|
||||
src={imageData}
|
||||
src={resolveServerPath(imageData)}
|
||||
alt="Floor plan"
|
||||
draggable={false}
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { FloorMapLayer } from '../FloorMapLayer'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { FloorMapConfig } from '@/types'
|
||||
@@ -43,6 +43,16 @@ describe('FloorMapLayer', () => {
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the stored image URL as-is at the root, base64 plans included', () => {
|
||||
setFloorMap()
|
||||
const { rerender } = render(<FloorMapLayer />)
|
||||
expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('/api/v1/media/abc.png')
|
||||
|
||||
act(() => setFloorMap({ imageData: 'data:image/png;base64,abc' }))
|
||||
rerender(<FloorMapLayer />)
|
||||
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(<FloorMapLayer />)
|
||||
|
||||
@@ -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({
|
||||
) : (
|
||||
<>
|
||||
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 160 }}>
|
||||
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||
<img src={resolveServerPath(imageData)} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -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 /<base>/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, '/'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" <<EOF
|
||||
if [[ "$BASE_PATH" == "/" ]]; then
|
||||
cat >"$SITE" <<EOF
|
||||
server {
|
||||
listen $HTTP_PORT;
|
||||
server_name $SERVER_NAME;
|
||||
@@ -262,6 +271,94 @@ server {
|
||||
}
|
||||
}
|
||||
EOF
|
||||
else
|
||||
# Under a prefix the SPA is served from a webroot where the build hangs off
|
||||
# $BASE_PATH, so nginx keeps plain `root` semantics — `alias` plus `try_files`
|
||||
# mis-resolves \$uri. The symlink is refreshed on every run.
|
||||
WEBROOT="/var/www/homelable"
|
||||
mkdir -p "$(dirname "${WEBROOT}${BASE_PATH_NO_SLASH}")"
|
||||
ln -sfn "$FRONTEND_DIR/dist" "${WEBROOT}${BASE_PATH_NO_SLASH}"
|
||||
chmod -R o+rX "$WEBROOT"
|
||||
cat >"$SITE" <<EOF
|
||||
server {
|
||||
listen $HTTP_PORT;
|
||||
server_name $SERVER_NAME;
|
||||
root $WEBROOT;
|
||||
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;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
# --- served under $BASE_PATH ---------------------------------------------
|
||||
location = $BASE_PATH_NO_SLASH {
|
||||
return 301 $BASE_PATH;
|
||||
}
|
||||
|
||||
# WebSocket (must come before the API block to take priority)
|
||||
location ${BASE_PATH}api/v1/status/ws/ {
|
||||
proxy_pass http://127.0.0.1:$BACKEND_PORT/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_PATH}api/ {
|
||||
proxy_pass http://127.0.0.1:$BACKEND_PORT/api/;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
}
|
||||
|
||||
# Legacy /ws/ path
|
||||
location ${BASE_PATH}ws/ {
|
||||
proxy_pass http://127.0.0.1:$BACKEND_PORT/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_PATH {
|
||||
try_files \$uri \$uri/ ${BASE_PATH}index.html;
|
||||
}
|
||||
|
||||
# --- prefix already stripped by a front proxy ----------------------------
|
||||
# No redirect to $BASE_PATH here: the front proxy would strip it again and loop.
|
||||
location /api/v1/status/ws/ {
|
||||
proxy_pass http://127.0.0.1:$BACKEND_PORT;
|
||||
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://127.0.0.1:$BACKEND_PORT;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:$BACKEND_PORT;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
}
|
||||
|
||||
location / {
|
||||
root $FRONTEND_DIR/dist;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
ln -sf "$SITE" /etc/nginx/sites-enabled/homelable
|
||||
if [[ "$HTTP_PORT" == "80" ]]; then rm -f /etc/nginx/sites-enabled/default; fi
|
||||
nginx -t
|
||||
@@ -297,9 +394,14 @@ Homelable installed.
|
||||
EOF
|
||||
if [[ "$SKIP_NGINX" != "1" ]]; then
|
||||
cat <<EOF
|
||||
Web UI: http://${HOST_IP:-<host-ip>}:${HTTP_PORT}
|
||||
Web UI: http://${HOST_IP:-<host-ip>}:${HTTP_PORT}${BASE_PATH}
|
||||
nginx site: /etc/nginx/sites-available/homelable
|
||||
EOF
|
||||
if [[ "$BASE_PATH" != "/" ]]; then
|
||||
cat <<EOF
|
||||
Base path: $BASE_PATH (webroot /var/www/homelable, symlinked to the build)
|
||||
EOF
|
||||
fi
|
||||
else
|
||||
cat <<EOF
|
||||
nginx: skipped — proxy your own front end to 127.0.0.1:$BACKEND_PORT
|
||||
|
||||
Reference in New Issue
Block a user