diff --git a/src/server/__tests__/h5-access-service.test.ts b/src/server/__tests__/h5-access-service.test.ts index 90cdc408..79a57746 100644 --- a/src/server/__tests__/h5-access-service.test.ts +++ b/src/server/__tests__/h5-access-service.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' -import { H5AccessService } from '../services/h5AccessService.js' +import { + H5AccessService, + resolveEffectiveH5PublicBaseUrl, +} from '../services/h5AccessService.js' import { ProviderService } from '../services/providerService.js' let tmpDir: string @@ -85,6 +88,41 @@ describe('H5AccessService', () => { expect(result.settings.publicBaseUrl).toBe('http://192.168.1.20:28670') }) + test('configured public URL overrides stale stored local URLs', async () => { + const service = new H5AccessService() + await service.updateSettings({ + publicBaseUrl: 'http://192.168.0.102:5179', + }) + + process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'https://chat.example.com/app/' + const result = await service.enable() + + expect(result.settings.publicBaseUrl).toBe('https://chat.example.com/app') + }) + + test('auto LAN mode replaces stale local public URLs but keeps public reverse proxies', () => { + expect(resolveEffectiveH5PublicBaseUrl({ + enabled: true, + storedPublicBaseUrl: 'http://192.168.0.102:5179', + configuredPublicBaseUrl: null, + autoPublicBaseUrl: 'http://192.168.0.102:39876', + })).toBe('http://192.168.0.102:39876') + + expect(resolveEffectiveH5PublicBaseUrl({ + enabled: true, + storedPublicBaseUrl: 'http://127.0.0.1:5179', + configuredPublicBaseUrl: null, + autoPublicBaseUrl: 'http://192.168.0.102:39876', + })).toBe('http://192.168.0.102:39876') + + expect(resolveEffectiveH5PublicBaseUrl({ + enabled: true, + storedPublicBaseUrl: 'https://chat.example.com/app', + configuredPublicBaseUrl: null, + autoPublicBaseUrl: 'http://192.168.0.102:39876', + })).toBe('https://chat.example.com/app') + }) + test('regenerateToken invalidates the previous token', async () => { const service = new H5AccessService() diff --git a/src/server/services/h5AccessService.ts b/src/server/services/h5AccessService.ts index 0e3d2538..5e03194e 100644 --- a/src/server/services/h5AccessService.ts +++ b/src/server/services/h5AccessService.ts @@ -39,7 +39,12 @@ function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings { enabled: settings.enabled, tokenPreview: settings.tokenPreview, allowedOrigins: settings.allowedOrigins, - publicBaseUrl: settings.publicBaseUrl ?? (settings.enabled ? resolveAutoPublicBaseUrl() : null), + publicBaseUrl: resolveEffectiveH5PublicBaseUrl({ + enabled: settings.enabled, + storedPublicBaseUrl: settings.publicBaseUrl, + configuredPublicBaseUrl: resolveConfiguredPublicBaseUrl(), + autoPublicBaseUrl: resolveAutoLanPublicBaseUrl(), + }), } } @@ -121,7 +126,7 @@ function normalizePublicBaseUrl(input: unknown): string | null { return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}` } -function resolveAutoPublicBaseUrl(): string | null { +function resolveConfiguredPublicBaseUrl(): string | null { const configured = process.env.CLAUDE_H5_PUBLIC_BASE_URL if (configured) { try { @@ -131,6 +136,10 @@ function resolveAutoPublicBaseUrl(): string | null { } } + return null +} + +function resolveAutoLanPublicBaseUrl(): string | null { if (process.env.CLAUDE_H5_AUTO_PUBLIC_URL !== '1') { return null } @@ -143,6 +152,60 @@ function resolveAutoPublicBaseUrl(): string | null { return `http://${host}:${ProviderService.getServerPort()}` } +export function resolveEffectiveH5PublicBaseUrl({ + enabled, + storedPublicBaseUrl, + configuredPublicBaseUrl, + autoPublicBaseUrl, +}: { + enabled: boolean + storedPublicBaseUrl: string | null + configuredPublicBaseUrl: string | null + autoPublicBaseUrl: string | null +}): string | null { + if (!enabled) { + return storedPublicBaseUrl + } + + if (configuredPublicBaseUrl) { + return configuredPublicBaseUrl + } + + if (!autoPublicBaseUrl) { + return storedPublicBaseUrl + } + + if (!storedPublicBaseUrl || isLocalOrPrivatePublicBaseUrl(storedPublicBaseUrl)) { + return autoPublicBaseUrl + } + + return storedPublicBaseUrl +} + +function isLocalOrPrivatePublicBaseUrl(value: string): boolean { + try { + const hostname = new URL(value).hostname + .trim() + .replace(/^\[/, '') + .replace(/\]$/, '') + .toLowerCase() + return isLocalOrPrivateHost(hostname) + } catch { + return false + } +} + +function isLocalOrPrivateHost(hostname: string): boolean { + return hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '0.0.0.0' || + isPrivateIPv4(hostname) || + hostname.startsWith('fc') || + hostname.startsWith('fd') || + hostname.startsWith('fe80:') +} + function findPrivateLanAddress(): string | null { for (const entries of Object.values(os.networkInterfaces())) { for (const entry of entries ?? []) {