mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Merge H5 LAN address fix into local main
This commit is contained in:
commit
a0f702aaeb
@ -857,6 +857,7 @@ describe('Settings > General tab', () => {
|
||||
fireEvent.click(screen.getByText('H5 Access'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
|
||||
expect(within(section).getByLabelText('Access host / IP')).toHaveValue('https://phone.example/app')
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Copy H5 URL' }))
|
||||
})
|
||||
@ -878,7 +879,35 @@ describe('Settings > General tab', () => {
|
||||
expect(within(section).getByText('H5 unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates H5 public URL from General settings', async () => {
|
||||
it('updates H5 host by reusing the current service port', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5a1b2c3',
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: 'http://172.20.16.1:54064',
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('H5 Access'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
expect(within(section).getByLabelText('Current port')).toHaveValue('54064')
|
||||
fireEvent.change(within(section).getByLabelText('Access host / IP'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' }))
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
|
||||
publicBaseUrl: 'http://192.168.1.100:54064',
|
||||
})
|
||||
})
|
||||
|
||||
it('still accepts a full H5 public URL for reverse proxy setups', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
@ -892,7 +921,7 @@ describe('Settings > General tab', () => {
|
||||
fireEvent.click(screen.getByText('H5 Access'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
fireEvent.change(within(section).getByLabelText('Public URL'), {
|
||||
fireEvent.change(within(section).getByLabelText('Access host / IP'), {
|
||||
target: { value: 'https://phone.example/app' },
|
||||
})
|
||||
|
||||
|
||||
@ -879,12 +879,16 @@ export const en = {
|
||||
'settings.general.h5AccessLaunchUrlCopied': 'QR link copied.',
|
||||
'settings.general.h5AccessHideToken': 'Hide token',
|
||||
'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new token.',
|
||||
'settings.general.h5AccessPublicHost': 'Access host / IP',
|
||||
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
|
||||
'settings.general.h5AccessCurrentPort': 'Current port',
|
||||
'settings.general.h5AccessCurrentPortUnknown': 'Auto',
|
||||
'settings.general.h5AccessPublicUrl': 'Public URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': 'Allowed origins',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.',
|
||||
'settings.general.h5AccessOpenHint': 'Leave blank to use the detected LAN URL. Set this only when you expose H5 through a stable address.',
|
||||
'settings.general.h5AccessOpenHint': 'For LAN access, change only the host / IP; the current service port is reused. Enter a full URL for reverse proxies.',
|
||||
'settings.general.h5AccessSave': 'Save H5 settings',
|
||||
'settings.general.h5AccessUrl': 'H5 URL',
|
||||
'settings.general.h5AccessQrTitle': 'Phone connection',
|
||||
|
||||
@ -881,12 +881,16 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.h5AccessLaunchUrlCopied': '扫码链接已复制。',
|
||||
'settings.general.h5AccessHideToken': '隐藏令牌',
|
||||
'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。',
|
||||
'settings.general.h5AccessPublicHost': '访问主机 / IP',
|
||||
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
|
||||
'settings.general.h5AccessCurrentPort': '当前端口',
|
||||
'settings.general.h5AccessCurrentPortUnknown': '自动',
|
||||
'settings.general.h5AccessPublicUrl': '公开访问 URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': '允许的来源',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。',
|
||||
'settings.general.h5AccessOpenHint': '留空时使用自动探测的局域网地址。只有通过固定地址暴露 H5 时才需要填写。',
|
||||
'settings.general.h5AccessOpenHint': '普通局域网访问只改主机 / IP,端口使用当前服务端口。反向代理可直接填完整 URL。',
|
||||
'settings.general.h5AccessSave': '保存 H5 设置',
|
||||
'settings.general.h5AccessUrl': 'H5 链接',
|
||||
'settings.general.h5AccessQrTitle': '手机连接',
|
||||
|
||||
@ -73,6 +73,58 @@ function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string
|
||||
}
|
||||
}
|
||||
|
||||
function isLanH5BaseUrl(url: URL): boolean {
|
||||
return url.protocol === 'http:' &&
|
||||
!!url.port &&
|
||||
(
|
||||
url.hostname === 'localhost' ||
|
||||
url.hostname === '127.0.0.1' ||
|
||||
url.hostname.startsWith('10.') ||
|
||||
url.hostname.startsWith('192.168.') ||
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(url.hostname) ||
|
||||
url.hostname.startsWith('169.254.')
|
||||
)
|
||||
}
|
||||
|
||||
function extractH5AccessAddressDraft(baseUrl: string | null): string {
|
||||
if (!baseUrl) return ''
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
return isLanH5BaseUrl(url) ? url.hostname : baseUrl
|
||||
} catch {
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
function extractH5AccessPort(baseUrl: string | null): string | null {
|
||||
if (!baseUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
return url.port || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string | null): string | null {
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) return null
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
|
||||
|
||||
try {
|
||||
const current = currentBaseUrl ? new URL(currentBaseUrl) : null
|
||||
if (!current) return trimmed
|
||||
|
||||
const port = current.port ? `:${current.port}` : ''
|
||||
const path = current.pathname === '/' ? '' : current.pathname.replace(/\/+$/, '')
|
||||
return `${current.protocol}//${trimmed}${port}${path}`
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
|
||||
@ -2465,7 +2517,7 @@ function H5AccessSettings() {
|
||||
} = useSettingsStore()
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '')
|
||||
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
|
||||
const [h5GeneratedToken, setH5GeneratedToken] = useState<string | null>(null)
|
||||
const [h5TokenVisible, setH5TokenVisible] = useState(false)
|
||||
const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false)
|
||||
@ -2476,10 +2528,12 @@ function H5AccessSettings() {
|
||||
() => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken),
|
||||
[h5AccessUrl, h5GeneratedToken],
|
||||
)
|
||||
const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '')
|
||||
const h5AccessPort = extractH5AccessPort(h5AccessUrl)
|
||||
const h5NextPublicBaseUrl = buildH5PublicBaseUrlFromHostDraft(h5PublicBaseUrlDraft, h5Access.publicBaseUrl)
|
||||
const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '')
|
||||
setH5PublicBaseUrlDraft(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
|
||||
}, [h5Access])
|
||||
|
||||
useEffect(() => {
|
||||
@ -2518,7 +2572,7 @@ function H5AccessSettings() {
|
||||
const handleH5SettingsSave = async () => {
|
||||
await runH5Action(async () => {
|
||||
await updateH5AccessSettings({
|
||||
publicBaseUrl: h5PublicBaseUrlDraft.trim() || null,
|
||||
publicBaseUrl: h5NextPublicBaseUrl,
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -2624,13 +2678,22 @@ function H5AccessSettings() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||
<Input
|
||||
id="h5-access-public-url"
|
||||
label={t('settings.general.h5AccessPublicUrl')}
|
||||
value={h5PublicBaseUrlDraft}
|
||||
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
|
||||
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<Input
|
||||
id="h5-access-public-url"
|
||||
label={t('settings.general.h5AccessPublicHost')}
|
||||
value={h5PublicBaseUrlDraft}
|
||||
placeholder={t('settings.general.h5AccessPublicHostPlaceholder')}
|
||||
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="h5-access-current-port"
|
||||
label={t('settings.general.h5AccessCurrentPort')}
|
||||
value={h5AccessPort ?? t('settings.general.h5AccessCurrentPortUnknown')}
|
||||
readOnly
|
||||
className="text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessOpenHint')}
|
||||
|
||||
@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import {
|
||||
findPrivateLanAddress,
|
||||
H5AccessService,
|
||||
resolveEffectiveH5PublicBaseUrl,
|
||||
} from '../services/h5AccessService.js'
|
||||
@ -100,10 +101,10 @@ describe('H5AccessService', () => {
|
||||
expect(result.settings.publicBaseUrl).toBe('https://chat.example.com/app')
|
||||
})
|
||||
|
||||
test('auto LAN mode replaces stale local public URLs but keeps public reverse proxies', () => {
|
||||
test('auto LAN mode fills blank or loopback URLs but preserves manual LAN URLs', () => {
|
||||
expect(resolveEffectiveH5PublicBaseUrl({
|
||||
enabled: true,
|
||||
storedPublicBaseUrl: 'http://192.168.0.102:5179',
|
||||
storedPublicBaseUrl: null,
|
||||
configuredPublicBaseUrl: null,
|
||||
autoPublicBaseUrl: 'http://192.168.0.102:39876',
|
||||
})).toBe('http://192.168.0.102:39876')
|
||||
@ -115,6 +116,13 @@ describe('H5AccessService', () => {
|
||||
autoPublicBaseUrl: 'http://192.168.0.102:39876',
|
||||
})).toBe('http://192.168.0.102:39876')
|
||||
|
||||
expect(resolveEffectiveH5PublicBaseUrl({
|
||||
enabled: true,
|
||||
storedPublicBaseUrl: 'http://192.168.1.100:54064',
|
||||
configuredPublicBaseUrl: null,
|
||||
autoPublicBaseUrl: 'http://172.20.16.1:39876',
|
||||
})).toBe('http://192.168.1.100:54064')
|
||||
|
||||
expect(resolveEffectiveH5PublicBaseUrl({
|
||||
enabled: true,
|
||||
storedPublicBaseUrl: 'https://chat.example.com/app',
|
||||
@ -123,6 +131,35 @@ describe('H5AccessService', () => {
|
||||
})).toBe('https://chat.example.com/app')
|
||||
})
|
||||
|
||||
test('auto LAN detection prefers physical adapters over WSL and Docker virtual adapters', () => {
|
||||
expect(findPrivateLanAddress({
|
||||
'vEthernet (WSL)': [{
|
||||
address: '172.20.16.1',
|
||||
netmask: '255.255.240.0',
|
||||
family: 'IPv4',
|
||||
mac: '00:15:5d:00:00:01',
|
||||
internal: false,
|
||||
cidr: '172.20.16.1/20',
|
||||
}],
|
||||
'Docker Desktop': [{
|
||||
address: '172.17.0.1',
|
||||
netmask: '255.255.0.0',
|
||||
family: 'IPv4',
|
||||
mac: '02:42:ac:11:00:01',
|
||||
internal: false,
|
||||
cidr: '172.17.0.1/16',
|
||||
}],
|
||||
'Wi-Fi': [{
|
||||
address: '192.168.1.100',
|
||||
netmask: '255.255.255.0',
|
||||
family: 'IPv4',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
internal: false,
|
||||
cidr: '192.168.1.100/24',
|
||||
}],
|
||||
})).toBe('192.168.1.100')
|
||||
})
|
||||
|
||||
test('regenerateToken invalidates the previous token', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
|
||||
@ -175,47 +175,89 @@ export function resolveEffectiveH5PublicBaseUrl({
|
||||
return storedPublicBaseUrl
|
||||
}
|
||||
|
||||
if (!storedPublicBaseUrl || isLocalOrPrivatePublicBaseUrl(storedPublicBaseUrl)) {
|
||||
if (!storedPublicBaseUrl || isLocalPublicBaseUrl(storedPublicBaseUrl)) {
|
||||
return autoPublicBaseUrl
|
||||
}
|
||||
|
||||
return storedPublicBaseUrl
|
||||
}
|
||||
|
||||
function isLocalOrPrivatePublicBaseUrl(value: string): boolean {
|
||||
function isLocalPublicBaseUrl(value: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(value).hostname
|
||||
.trim()
|
||||
.replace(/^\[/, '')
|
||||
.replace(/\]$/, '')
|
||||
.toLowerCase()
|
||||
return isLocalOrPrivateHost(hostname)
|
||||
return isLocalHost(hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalOrPrivateHost(hostname: string): boolean {
|
||||
function isLocalHost(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:')
|
||||
hostname === '::'
|
||||
}
|
||||
|
||||
function findPrivateLanAddress(): string | null {
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
type NetworkInterfaces = ReturnType<typeof os.networkInterfaces>
|
||||
|
||||
const PHYSICAL_INTERFACE_RE = /\b(wi-?fi|wlan|wireless|ethernet|lan|en\d+|eth\d+)\b/i
|
||||
const VIRTUAL_INTERFACE_RE = /\b(wsl|docker|hyper-?v|veth|vethernet|virtual|virtualbox|vmware|podman|container|bridge|br-|tailscale|zerotier|utun|vpn)\b/i
|
||||
|
||||
export function findPrivateLanAddress(networkInterfaces: NetworkInterfaces = os.networkInterfaces()): string | null {
|
||||
const candidates: Array<{
|
||||
address: string
|
||||
interfaceName: string
|
||||
index: number
|
||||
score: number
|
||||
}> = []
|
||||
|
||||
let index = 0
|
||||
for (const [interfaceName, entries] of Object.entries(networkInterfaces)) {
|
||||
for (const entry of entries ?? []) {
|
||||
if (entry.family !== 'IPv4' || entry.internal || !isPrivateIPv4(entry.address)) {
|
||||
continue
|
||||
}
|
||||
return entry.address
|
||||
|
||||
candidates.push({
|
||||
address: entry.address,
|
||||
interfaceName,
|
||||
index,
|
||||
score: scoreLanAddressCandidate(interfaceName, entry.address),
|
||||
})
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
return candidates[0]?.address ?? null
|
||||
}
|
||||
|
||||
function scoreLanAddressCandidate(interfaceName: string, address: string): number {
|
||||
let score = 0
|
||||
|
||||
if (PHYSICAL_INTERFACE_RE.test(interfaceName)) {
|
||||
score += 100
|
||||
}
|
||||
if (VIRTUAL_INTERFACE_RE.test(interfaceName)) {
|
||||
score -= 200
|
||||
}
|
||||
|
||||
if (address.startsWith('192.168.')) {
|
||||
score += 30
|
||||
} else if (address.startsWith('10.')) {
|
||||
score += 20
|
||||
} else if (is172PrivateIPv4(address)) {
|
||||
score += 10
|
||||
} else if (address.startsWith('169.254.')) {
|
||||
score -= 100
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string): boolean {
|
||||
@ -227,12 +269,22 @@ function isPrivateIPv4(address: string): boolean {
|
||||
const [a = -1, b = -1] = parts.map((part) => Number(part))
|
||||
return (
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
is172PrivateIPv4(address) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
)
|
||||
}
|
||||
|
||||
function is172PrivateIPv4(address: string): boolean {
|
||||
const parts = address.split('.')
|
||||
if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [a = -1, b = -1] = parts.map((part) => Number(part))
|
||||
return a === 172 && b >= 16 && b <= 31
|
||||
}
|
||||
|
||||
function normalizeStoredSettings(value: unknown): StoredH5AccessSettings {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_STORED_SETTINGS }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user