From 0be023b23b50ff767a95bab3289d7d47bb8b1520 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 25 Dec 2025 17:40:51 +0800 Subject: [PATCH] feat: add configurable server URL for standalone web hosting Adds support for hosting the web UI separately from the hapi server on static hosts (GitHub Pages, Cloudflare Pages). Users can now set a custom server origin via a dialog on the login screen, with the ability to return to same-origin behavior. Changes include: - New useServerUrl hook for managing server URL configuration and storage - Updated API client to support baseUrl parameter for all requests - Enhanced login UI with server picker dialog (top-right button) - Auth system now keys tokens per baseUrl to support multiple servers - SSE connection updated to use configured baseUrl - Documentation updates for standalone hosting setup --- server/README.md | 14 +++- web/README.md | 17 +++++ web/src/App.tsx | 32 ++++++++- web/src/api/client.ts | 18 ++++- web/src/components/LoginPrompt.tsx | 102 ++++++++++++++++++++++++++++- web/src/hooks/useAuth.ts | 22 +++++-- web/src/hooks/useAuthSource.ts | 40 ++++++----- web/src/hooks/useSSE.ts | 14 ++-- web/src/hooks/useServerUrl.ts | 94 ++++++++++++++++++++++++++ 9 files changed, 318 insertions(+), 35 deletions(-) create mode 100644 web/src/hooks/useServerUrl.ts diff --git a/server/README.md b/server/README.md index 6af05db8..250010c4 100644 --- a/server/README.md +++ b/server/README.md @@ -23,7 +23,7 @@ See `src/configuration.ts` for all options. - `TELEGRAM_BOT_TOKEN` - Token from @BotFather. - `ALLOWED_CHAT_IDS` - Comma-separated chat IDs allowed to use the bot. -- `WEBAPP_URL` - Public HTTPS URL for Telegram Mini App access. +- `WEBAPP_URL` - Public HTTPS URL for Telegram Mini App access. Also used to derive default CORS origins for the web app. ### Optional @@ -200,4 +200,14 @@ The server build output is `server/dist/index.js`, and the web assets are in `we ## Networking notes - Telegram Mini Apps require HTTPS and a public URL. If the server has no public IP, use Cloudflare Tunnel or Tailscale and set `WEBAPP_URL` to the HTTPS endpoint. -- If the web app is hosted on a different origin, set `CORS_ORIGINS` accordingly. +- If the web app is hosted on a different origin, set `CORS_ORIGINS` (or `WEBAPP_URL`) to include that static host origin. + +## Standalone web hosting + +The web UI can be hosted separately from the server (for example on GitHub Pages or Cloudflare Pages): + +1. Build and deploy `web/dist` from the repo root. +2. Set `CORS_ORIGINS` (or `WEBAPP_URL`) to the static host origin. +3. Open the static site, click the Server button on the login screen, and enter the hapi server origin. + +Leaving the server override empty preserves the default same-origin behavior when the server serves the web assets directly. diff --git a/web/README.md b/web/README.md index 1437bd16..0bfe2e08 100644 --- a/web/README.md +++ b/web/README.md @@ -16,6 +16,7 @@ React Mini App / PWA for monitoring and controlling hapi sessions. - When opened inside Telegram, auth uses Telegram WebApp init data. - When opened in a normal browser, you can log in with the shared `CLI_API_TOKEN`. +- The login screen includes a top-right server picker; if unset, the app uses the same origin it was loaded from. - Live updates come from the server via SSE. ## Routes @@ -122,3 +123,19 @@ bun run build:web ``` The built assets land in `web/dist` and are served by hapi-server. The single executable can embed these assets. + +## Standalone hosting + +You can host `web/dist` on a static host (GitHub Pages, Cloudflare Pages) and point it at any hapi server: + +1. Build the web app. If your static host uses a subpath, set the Vite base: + +```bash +bun run build:web -- --base // +``` + +2. Deploy `web/dist` to your static host. +3. Set server CORS to allow the static origin (`WEBAPP_URL` or `CORS_ORIGINS`). +4. Open the static site, click the top-right Server button on the login screen, and enter the hapi server origin. + +Clear the server override in the same dialog to return to same-origin behavior. diff --git a/web/src/App.tsx b/web/src/App.tsx index 72c61060..1904dfba 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { getTelegramWebApp } from '@/hooks/useTelegram' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' +import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { queryKeys } from '@/lib/query-keys' @@ -17,8 +18,9 @@ import { SyncingBanner } from '@/components/SyncingBanner' import { LoadingState } from '@/components/LoadingState' export function App() { - const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource() - const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource) + const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() + const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) + const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource, baseUrl) const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) const matchRoute = useMatchRoute() @@ -90,6 +92,17 @@ export function App() { const { isSyncing, startSync, endSync } = useSyncingState() const syncTokenRef = useRef(0) const isFirstConnectRef = useRef(true) + const baseUrlRef = useRef(baseUrl) + + useEffect(() => { + if (baseUrlRef.current === baseUrl) { + return + } + baseUrlRef.current = baseUrl + isFirstConnectRef.current = true + syncTokenRef.current = 0 + queryClient.clear() + }, [baseUrl, queryClient]) const handleSseConnect = useCallback(() => { // Increment token to track this specific connection @@ -135,6 +148,7 @@ export function App() { useSSE({ enabled: Boolean(api && token), token: token ?? '', + baseUrl, subscription: eventSubscription, onConnect: handleSseConnect, onEvent: handleSseEvent, @@ -151,7 +165,15 @@ export function App() { // No auth source (browser environment, not logged in) if (!authSource) { - return + return ( + + ) } // Authenticating (also covers the gap before useAuth effect starts) @@ -170,6 +192,10 @@ export function App() { return ( ) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6db1bd49..98139fbe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -11,21 +11,35 @@ import type { } from '@/types/api' type ApiClientOptions = { + baseUrl?: string getToken?: () => string | null onUnauthorized?: () => Promise } export class ApiClient { private token: string + private readonly baseUrl: string | null private readonly getToken: (() => string | null) | null private readonly onUnauthorized: (() => Promise) | null constructor(token: string, options?: ApiClientOptions) { this.token = token + this.baseUrl = options?.baseUrl ?? null this.getToken = options?.getToken ?? null this.onUnauthorized = options?.onUnauthorized ?? null } + private buildUrl(path: string): string { + if (!this.baseUrl) { + return path + } + try { + return new URL(path, this.baseUrl).toString() + } catch { + return path + } + } + private async request( path: string, init?: RequestInit, @@ -44,7 +58,7 @@ export class ApiClient { headers.set('content-type', 'application/json') } - const res = await fetch(path, { + const res = await fetch(this.buildUrl(path), { ...init, headers }) @@ -69,7 +83,7 @@ export class ApiClient { } async authenticate(auth: { initData: string } | { accessToken: string }): Promise { - const res = await fetch('/api/auth', { + const res = await fetch(this.buildUrl('/api/auth'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(auth) diff --git a/web/src/components/LoginPrompt.tsx b/web/src/components/LoginPrompt.tsx index 9e444455..01f5c3f9 100644 --- a/web/src/components/LoginPrompt.tsx +++ b/web/src/components/LoginPrompt.tsx @@ -1,9 +1,16 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { ApiClient } from '@/api/client' import { Spinner } from '@/components/Spinner' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import type { ServerUrlResult } from '@/hooks/useServerUrl' type LoginPromptProps = { onLogin: (token: string) => void + baseUrl: string + serverUrl: string | null + setServerUrl: (input: string) => ServerUrlResult + clearServerUrl: () => void error?: string | null } @@ -11,6 +18,9 @@ export function LoginPrompt(props: LoginPromptProps) { const [accessToken, setAccessToken] = useState('') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [isServerDialogOpen, setIsServerDialogOpen] = useState(false) + const [serverInput, setServerInput] = useState(props.serverUrl ?? '') + const [serverError, setServerError] = useState(null) const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault() @@ -26,7 +36,7 @@ export function LoginPrompt(props: LoginPromptProps) { try { // Validate the token by attempting to authenticate - const client = new ApiClient('') + const client = new ApiClient('', { baseUrl: props.baseUrl }) await client.authenticate({ accessToken: trimmedToken }) // If successful, pass the token to parent props.onLogin(trimmedToken) @@ -37,10 +47,96 @@ export function LoginPrompt(props: LoginPromptProps) { } }, [accessToken, props]) + useEffect(() => { + if (!isServerDialogOpen) { + return + } + setServerInput(props.serverUrl ?? '') + setServerError(null) + }, [isServerDialogOpen, props.serverUrl]) + + const handleSaveServer = useCallback((e: React.FormEvent) => { + e.preventDefault() + const result = props.setServerUrl(serverInput) + if (!result.ok) { + setServerError(result.error) + return + } + setServerError(null) + setServerInput(result.value) + setIsServerDialogOpen(false) + }, [props, serverInput]) + + const handleClearServer = useCallback(() => { + props.clearServerUrl() + setServerInput('') + setServerError(null) + setIsServerDialogOpen(false) + }, [props]) + const displayError = error || props.error + const serverSummary = props.serverUrl ?? `${props.baseUrl} (same origin)` return ( -
+
+
+ + + + + + + Server URL + + Set the hapi server origin for API and live updates. + + +
+
+ Current: {serverSummary} +
+
+ + { + setServerInput(e.target.value) + setServerError(null) + }} + placeholder="https://hapi.example.com" + className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent" + /> +
+ Use http(s) only. Any path is ignored. +
+
+ + {serverError && ( +
+ {serverError} +
+ )} + +
+ {props.serverUrl && ( + + )} + +
+
+
+
+
{/* Header */}
diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 44cc0754..43ff589b 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -33,7 +33,7 @@ function getAuthPayload(source: AuthSource): { initData: string } | { accessToke return { accessToken: source.token } } -export function useAuth(authSource: AuthSource | null): { +export function useAuth(authSource: AuthSource | null, baseUrl: string): { token: string | null user: AuthResponse['user'] | null api: ApiClient | null @@ -83,7 +83,7 @@ export function useAuth(authSource: AuthSource | null): { lastRefreshAttemptRef.current = now try { - const client = new ApiClient('') + const client = new ApiClient('', { baseUrl }) const auth = await client.authenticate(getAuthPayload(currentSource)) tokenRef.current = auth.token setToken(auth.token) @@ -115,16 +115,17 @@ export function useAuth(authSource: AuthSource | null): { refreshPromiseRef.current = null } } - }, []) + }, [baseUrl]) const api = useMemo(() => ( token ? new ApiClient(token, { + baseUrl, getToken: () => tokenRef.current, onUnauthorized: () => refreshAuth({ force: true }) }) : null - ), [refreshAuth, token]) + ), [baseUrl, refreshAuth, token]) useEffect(() => { let isCancelled = false @@ -138,7 +139,7 @@ export function useAuth(authSource: AuthSource | null): { setIsLoading(true) setError(null) try { - const client = new ApiClient('') // temporary for auth call + const client = new ApiClient('', { baseUrl }) // temporary for auth call const auth = await client.authenticate(getAuthPayload(authSource)) if (isCancelled) return setToken(auth.token) @@ -158,7 +159,16 @@ export function useAuth(authSource: AuthSource | null): { return () => { isCancelled = true } - }, [authSource]) + }, [authSource, baseUrl]) + + useEffect(() => { + tokenRef.current = null + refreshPromiseRef.current = null + lastRefreshAttemptRef.current = 0 + setToken(null) + setUser(null) + setError(null) + }, [baseUrl]) useEffect(() => { if (!token || !authSource) { diff --git a/web/src/hooks/useAuthSource.ts b/web/src/hooks/useAuthSource.ts index c3150f41..59b128f8 100644 --- a/web/src/hooks/useAuthSource.ts +++ b/web/src/hooks/useAuthSource.ts @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTelegramWebApp, isTelegramEnvironment } from './useTelegram' import type { AuthSource } from './useAuth' -const ACCESS_TOKEN_KEY = 'hapi_access_token' +const ACCESS_TOKEN_PREFIX = 'hapi_access_token::' function getTelegramInitData(): string | null { const tg = getTelegramWebApp() @@ -21,31 +21,35 @@ function getTelegramInitData(): string | null { return initData || null } -function getStoredAccessToken(): string | null { +function getAccessTokenKey(baseUrl: string): string { + return `${ACCESS_TOKEN_PREFIX}${baseUrl}` +} + +function getStoredAccessToken(key: string): string | null { try { - return localStorage.getItem(ACCESS_TOKEN_KEY) + return localStorage.getItem(key) } catch { return null } } -function storeAccessToken(token: string): void { +function storeAccessToken(key: string, token: string): void { try { - localStorage.setItem(ACCESS_TOKEN_KEY, token) + localStorage.setItem(key, token) } catch { // Ignore storage errors } } -function clearStoredAccessToken(): void { +function clearStoredAccessToken(key: string): void { try { - localStorage.removeItem(ACCESS_TOKEN_KEY) + localStorage.removeItem(key) } catch { // Ignore storage errors } } -export function useAuthSource(): { +export function useAuthSource(baseUrl: string): { authSource: AuthSource | null isLoading: boolean isTelegram: boolean @@ -56,9 +60,15 @@ export function useAuthSource(): { const [isLoading, setIsLoading] = useState(true) const [isTelegram, setIsTelegram] = useState(false) const retryCountRef = useRef(0) + const accessTokenKey = useMemo(() => getAccessTokenKey(baseUrl), [baseUrl]) // Initialize auth source on mount, with retry for delayed Telegram initData useEffect(() => { + retryCountRef.current = 0 + setAuthSource(null) + setIsTelegram(false) + setIsLoading(true) + const telegramInitData = getTelegramInitData() if (telegramInitData) { @@ -70,7 +80,7 @@ export function useAuthSource(): { } // Check for stored access token as fallback - const storedToken = getStoredAccessToken() + const storedToken = getStoredAccessToken(accessTokenKey) if (storedToken) { setAuthSource({ type: 'accessToken', token: storedToken }) setIsLoading(false) @@ -108,17 +118,17 @@ export function useAuthSource(): { return () => { clearInterval(interval) } - }, []) + }, [accessTokenKey]) const setAccessToken = useCallback((token: string) => { - storeAccessToken(token) + storeAccessToken(accessTokenKey, token) setAuthSource({ type: 'accessToken', token }) - }, []) + }, [accessTokenKey]) const clearAuth = useCallback(() => { - clearStoredAccessToken() + clearStoredAccessToken(accessTokenKey) setAuthSource(null) - }, []) + }, [accessTokenKey]) return { authSource, diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 4b6f205c..32408ed8 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -14,7 +14,7 @@ type SSESubscription = { machineId?: string } -function buildEventsUrl(token: string, subscription: SSESubscription): string { +function buildEventsUrl(baseUrl: string, token: string, subscription: SSESubscription): string { const params = new URLSearchParams() params.set('token', token) if (subscription.all) { @@ -27,12 +27,18 @@ function buildEventsUrl(token: string, subscription: SSESubscription): string { params.set('machineId', subscription.machineId) } - return `/api/events?${params.toString()}` + const path = `/api/events?${params.toString()}` + try { + return new URL(path, baseUrl).toString() + } catch { + return path + } } export function useSSE(options: { enabled: boolean token: string + baseUrl: string subscription?: SSESubscription onEvent: (event: SyncEvent) => void onConnect?: () => void @@ -74,7 +80,7 @@ export function useSSE(options: { return } - const url = buildEventsUrl(options.token, subscription) + const url = buildEventsUrl(options.baseUrl, options.token, subscription) const eventSource = new EventSource(url) eventSourceRef.current = eventSource @@ -148,5 +154,5 @@ export function useSSE(options: { eventSourceRef.current = null } } - }, [options.enabled, options.token, subscriptionKey, queryClient]) + }, [options.baseUrl, options.enabled, options.token, subscriptionKey, queryClient]) } diff --git a/web/src/hooks/useServerUrl.ts b/web/src/hooks/useServerUrl.ts new file mode 100644 index 00000000..f223f1e8 --- /dev/null +++ b/web/src/hooks/useServerUrl.ts @@ -0,0 +1,94 @@ +import { useCallback, useMemo, useState } from 'react' + +const SERVER_URL_KEY = 'hapi_server_url' + +export type ServerUrlResult = + | { ok: true; value: string } + | { ok: false; error: string } + +export function normalizeServerUrl(input: string): ServerUrlResult { + const trimmed = input.trim() + if (!trimmed) { + return { ok: false, error: 'Enter a server URL like https://example.com' } + } + + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return { ok: false, error: 'Enter a valid URL including http:// or https://' } + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: 'Server URL must start with http:// or https://' } + } + + return { ok: true, value: parsed.origin } +} + +function readStoredServerUrl(): string | null { + try { + const stored = localStorage.getItem(SERVER_URL_KEY) + if (!stored) { + return null + } + const normalized = normalizeServerUrl(stored) + if (!normalized.ok) { + localStorage.removeItem(SERVER_URL_KEY) + return null + } + return normalized.value + } catch { + return null + } +} + +function writeStoredServerUrl(value: string): void { + try { + localStorage.setItem(SERVER_URL_KEY, value) + } catch { + // Ignore storage errors + } +} + +function clearStoredServerUrl(): void { + try { + localStorage.removeItem(SERVER_URL_KEY) + } catch { + // Ignore storage errors + } +} + +export function useServerUrl(): { + serverUrl: string | null + baseUrl: string + setServerUrl: (input: string) => ServerUrlResult + clearServerUrl: () => void +} { + const [serverUrl, setServerUrlState] = useState(() => readStoredServerUrl()) + + const fallbackOrigin = typeof window !== 'undefined' ? window.location.origin : '' + const baseUrl = useMemo(() => serverUrl ?? fallbackOrigin, [serverUrl, fallbackOrigin]) + + const setServerUrl = useCallback((input: string): ServerUrlResult => { + const normalized = normalizeServerUrl(input) + if (!normalized.ok) { + return normalized + } + writeStoredServerUrl(normalized.value) + setServerUrlState(normalized.value) + return normalized + }, []) + + const clearServerUrl = useCallback(() => { + clearStoredServerUrl() + setServerUrlState(null) + }, []) + + return { + serverUrl, + baseUrl, + setServerUrl, + clearServerUrl + } +}