diff --git a/README_ADVANCED.md b/README_ADVANCED.md index ff79af4..f5d82bf 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -19,6 +19,15 @@ If the audit finds a mismatch, you'll see an error in the application UI and you `__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental. +## Sub-Path Reverse Proxy + +RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix. + +**Requirements:** + +- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing. +- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution. + ## HTTPS WebGPU channel-finding requires a secure context when you are not on `localhost`. diff --git a/app/frontend_static.py b/app/frontend_static.py index 49e8551..17e4e35 100644 --- a/app/frontend_static.py +++ b/app/frontend_static.py @@ -38,8 +38,17 @@ def _is_index_file(path: Path, index_file: Path) -> bool: return path == index_file -def _resolve_request_origin(request: Request) -> str: - """Resolve the external origin, honoring common reverse-proxy headers.""" +def _resolve_request_base(request: Request) -> str: + """Resolve the external base URL, honoring common reverse-proxy headers. + + Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash) + so callers can append paths directly. + + Recognized headers: + - ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host. + - ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added + by the proxy (e.g. ``/meshcore``). + """ forwarded_proto = request.headers.get("x-forwarded-proto") forwarded_host = request.headers.get("x-forwarded-host") @@ -47,9 +56,20 @@ def _resolve_request_origin(request: Request) -> str: proto = forwarded_proto.split(",")[0].strip() host = forwarded_host.split(",")[0].strip() if proto and host: - return f"{proto}://{host}" + origin = f"{proto}://{host}" + else: + origin = str(request.base_url).rstrip("/") + else: + origin = str(request.base_url).rstrip("/") - return str(request.base_url).rstrip("/") + # Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy + prefix = ( + (request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "") + .strip() + .rstrip("/") + ) + + return f"{origin}{prefix}/" def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]: @@ -103,27 +123,27 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool: @app.get("/site.webmanifest") async def serve_webmanifest(request: Request): - """Serve a dynamic web manifest using the active request origin.""" - origin = _resolve_request_origin(request) + """Serve a dynamic web manifest using the active request base URL.""" + base = _resolve_request_base(request) manifest = { "name": "RemoteTerm for MeshCore", "short_name": "RemoteTerm", - "id": f"{origin}/", - "start_url": f"{origin}/", - "scope": f"{origin}/", + "id": base, + "start_url": base, + "scope": base, "display": "standalone", "display_override": ["window-controls-overlay", "standalone", "fullscreen"], "theme_color": "#111419", "background_color": "#111419", "icons": [ { - "src": f"{origin}/web-app-manifest-192x192.png", + "src": f"{base}web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable", }, { - "src": f"{origin}/web-app-manifest-512x512.png", + "src": f"{base}web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable", diff --git a/frontend/index.html b/frontend/index.html index 37f1246..e414c36 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,11 +9,11 @@ RemoteTerm for MeshCore - - - - - + + + + +
- + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5962965..4707117 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -40,7 +40,7 @@ import type { UnreadCounts, } from './types'; -const API_BASE = '/api'; +const API_BASE = './api'; async function fetchJson(url: string, options?: RequestInit): Promise { const hasBody = options?.body !== undefined; diff --git a/frontend/src/components/settings/SettingsAboutSection.tsx b/frontend/src/components/settings/SettingsAboutSection.tsx index a3ee94b..fcdcf2c 100644 --- a/frontend/src/components/settings/SettingsAboutSection.tsx +++ b/frontend/src/components/settings/SettingsAboutSection.tsx @@ -131,7 +131,7 @@ export function SettingsAboutSection({
; diff --git a/frontend/src/hooks/useFaviconBadge.ts b/frontend/src/hooks/useFaviconBadge.ts index 2bfade6..cacc720 100644 --- a/frontend/src/hooks/useFaviconBadge.ts +++ b/frontend/src/hooks/useFaviconBadge.ts @@ -5,7 +5,7 @@ import { getStateKey } from '../utils/conversationState'; const APP_TITLE = 'RemoteTerm for MeshCore'; const UNREAD_APP_TITLE = 'RemoteTerm'; -const BASE_FAVICON_PATH = '/favicon.svg'; +const BASE_FAVICON_PATH = './favicon.svg'; const GREEN_BADGE_FILL = '#16a34a'; const RED_BADGE_FILL = '#dc2626'; const BADGE_CENTER = 750; diff --git a/frontend/src/test/api.test.ts b/frontend/src/test/api.test.ts index 2007773..0010782 100644 --- a/frontend/src/test/api.test.ts +++ b/frontend/src/test/api.test.ts @@ -105,7 +105,7 @@ describe('fetchJson (via api methods)', () => { expect(mockFetch).toHaveBeenCalledTimes(1); const [url] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/contacts?limit=100&offset=0'); + expect(url).toBe('./api/contacts?limit=100&offset=0'); }); it('builds repeater advert path endpoint query', async () => { @@ -118,7 +118,7 @@ describe('fetchJson (via api methods)', () => { await api.getRepeaterAdvertPaths(12); const [url] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12'); + expect(url).toBe('./api/contacts/repeaters/advert-paths?limit_per_repeater=12'); }); }); @@ -238,7 +238,7 @@ describe('fetchJson (via api methods)', () => { await api.sendDirectMessage('abc123', 'hello'); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/messages/direct'); + expect(url).toBe('./api/messages/direct'); expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ destination: 'abc123', @@ -256,7 +256,7 @@ describe('fetchJson (via api methods)', () => { await api.updateRadioConfig({ name: 'NewName' }); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/radio/config'); + expect(url).toBe('./api/radio/config'); expect(options.method).toBe('PATCH'); expect(JSON.parse(options.body)).toEqual({ name: 'NewName' }); }); @@ -271,7 +271,7 @@ describe('fetchJson (via api methods)', () => { await api.setPrivateKey('my-secret-key'); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/radio/private-key'); + expect(url).toBe('./api/radio/private-key'); expect(options.method).toBe('PUT'); expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' }); }); @@ -286,7 +286,7 @@ describe('fetchJson (via api methods)', () => { await api.discoverMesh('repeaters'); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/radio/discover'); + expect(url).toBe('./api/radio/discover'); expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' }); }); @@ -301,7 +301,7 @@ describe('fetchJson (via api methods)', () => { await api.deleteContact('pubkey123'); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/contacts/pubkey123'); + expect(url).toBe('./api/contacts/pubkey123'); expect(options.method).toBe('DELETE'); }); @@ -315,7 +315,7 @@ describe('fetchJson (via api methods)', () => { await api.sendAdvertisement(); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/radio/advertise'); + expect(url).toBe('./api/radio/advertise'); expect(options.method).toBe('POST'); expect(options.body).toBe(JSON.stringify({ mode: 'flood' })); }); @@ -330,7 +330,7 @@ describe('fetchJson (via api methods)', () => { await api.sendAdvertisement('zero_hop'); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/radio/advertise'); + expect(url).toBe('./api/radio/advertise'); expect(options.method).toBe('POST'); expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' })); }); @@ -383,7 +383,7 @@ describe('fetchJson (via api methods)', () => { }); const [url] = mockFetch.mock.calls[0]; - expect(url).toContain('/api/messages?'); + expect(url).toContain('./api/messages?'); expect(url).toContain('limit=50'); expect(url).toContain('offset=10'); expect(url).toContain('type=PRIV'); @@ -402,7 +402,7 @@ describe('fetchJson (via api methods)', () => { await api.getMessages(); const [url] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/messages'); + expect(url).toBe('./api/messages'); }); }); }); diff --git a/frontend/src/test/settingsAboutSection.test.tsx b/frontend/src/test/settingsAboutSection.test.tsx index b77e828..eae6d9f 100644 --- a/frontend/src/test/settingsAboutSection.test.tsx +++ b/frontend/src/test/settingsAboutSection.test.tsx @@ -25,7 +25,7 @@ describe('SettingsAboutSection', () => { ); const link = screen.getByRole('link', { name: /Open debug support snapshot/i }); - expect(link).toHaveAttribute('href', '/api/debug'); + expect(link).toHaveAttribute('href', './api/debug'); expect(link).toHaveAttribute('target', '_blank'); }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index a30ca29..068ce03 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -750,7 +750,7 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByRole('button', { name: /Statistics/i })); await waitFor(() => { - expect(fetchSpy).toHaveBeenCalledWith('/api/statistics', expect.any(Object)); + expect(fetchSpy).toHaveBeenCalledWith('./api/statistics', expect.any(Object)); }); await waitFor(() => { diff --git a/frontend/src/test/useBrowserNotifications.test.ts b/frontend/src/test/useBrowserNotifications.test.ts index db158be..b0e322e 100644 --- a/frontend/src/test/useBrowserNotifications.test.ts +++ b/frontend/src/test/useBrowserNotifications.test.ts @@ -89,7 +89,7 @@ describe('useBrowserNotifications', () => { ); expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', { body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.', - icon: '/favicon-256x256.png', + icon: './favicon-256x256.png', tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`, }); expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', { @@ -122,7 +122,7 @@ describe('useBrowserNotifications', () => { expect(window.Notification).toHaveBeenCalledTimes(2); expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', { body: 'hello room', - icon: '/favicon-256x256.png', + icon: './favicon-256x256.png', tag: 'meshcore-message-42', }); }); diff --git a/frontend/src/test/useFaviconBadge.test.ts b/frontend/src/test/useFaviconBadge.test.ts index 29070b3..3420039 100644 --- a/frontend/src/test/useFaviconBadge.test.ts +++ b/frontend/src/test/useFaviconBadge.test.ts @@ -172,8 +172,8 @@ describe('useFaviconBadge', () => { ); await waitFor(() => { - expect(getIconHref('icon')).toBe('/favicon.svg'); - expect(getIconHref('shortcut icon')).toBe('/favicon.svg'); + expect(getIconHref('icon')).toBe('./favicon.svg'); + expect(getIconHref('shortcut icon')).toBe('./favicon.svg'); }); rerender({ @@ -209,8 +209,8 @@ describe('useFaviconBadge', () => { }); await waitFor(() => { - expect(getIconHref('icon')).toBe('/favicon.svg'); - expect(getIconHref('shortcut icon')).toBe('/favicon.svg'); + expect(getIconHref('icon')).toBe('./favicon.svg'); + expect(getIconHref('shortcut icon')).toBe('./favicon.svg'); }); expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/frontend/src/useWebSocket.ts b/frontend/src/useWebSocket.ts index 76f69fc..8c72b18 100644 --- a/frontend/src/useWebSocket.ts +++ b/frontend/src/useWebSocket.ts @@ -54,7 +54,9 @@ export function useWebSocket(options: UseWebSocketOptions) { const connect = useCallback(() => { // Determine WebSocket URL based on current location const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/ws`; + // Resolve relative to the page so sub-path reverse proxies work + const base = new URL('./api/ws', window.location.href); + const wsUrl = `${protocol}//${base.host}${base.pathname}`; const ws = new WebSocket(wsUrl); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a5c0826..f99c25e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ + base: './', plugins: [react()], resolve: { alias: { diff --git a/tests/test_frontend_static.py b/tests/test_frontend_static.py index 5372df6..b569a2b 100644 --- a/tests/test_frontend_static.py +++ b/tests/test_frontend_static.py @@ -127,6 +127,34 @@ def test_webmanifest_uses_forwarded_origin_headers(tmp_path): assert data["id"] == "https://mesh.example.com:8443/" +def test_webmanifest_includes_forwarded_prefix(tmp_path): + app = FastAPI() + dist_dir = tmp_path / "frontend" / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("index page") + + registered = register_frontend_static_routes(app, dist_dir) + assert registered is True + + with TestClient(app) as client: + response = client.get( + "/site.webmanifest", + headers={ + "x-forwarded-proto": "https", + "x-forwarded-host": "homeassistant.local:8123", + "x-forwarded-prefix": "/api/hassio_ingress/abc123", + }, + ) + + assert response.status_code == 200 + data = response.json() + expected_base = "https://homeassistant.local:8123/api/hassio_ingress/abc123/" + assert data["start_url"] == expected_base + assert data["scope"] == expected_base + assert data["id"] == expected_base + assert data["icons"][0]["src"] == f"{expected_base}web-app-manifest-192x192.png" + + def test_first_available_prefers_dist_over_prebuilt(tmp_path): app = FastAPI() frontend_dir = tmp_path / "frontend"