diff options
Diffstat (limited to 'src/modules/backend/utils.ts')
| -rw-r--r-- | src/modules/backend/utils.ts | 98 |
1 files changed, 74 insertions, 24 deletions
diff --git a/src/modules/backend/utils.ts b/src/modules/backend/utils.ts index 47d2bd1..7d63251 100644 --- a/src/modules/backend/utils.ts +++ b/src/modules/backend/utils.ts @@ -1,41 +1,88 @@ import { fetchConfigs } from '~/api/configs'; import type { ClashAPIConfig } from '~/types'; -export const DEFAULT_API_BASE_URL = 'http://127.0.0.1:9090'; +export type Protocol = 'http' | 'https'; -const Ok = 0; +/** 后端地址在表单里被拆成三段独立编辑 */ +export type BackendFields = { + protocol: Protocol; + host: string; + port: string; +}; -export function normalizeAPIBaseURL(baseURL: string, currentProtocol: string) { - let normalizedBaseURL = baseURL || DEFAULT_API_BASE_URL; +export const DEFAULT_BACKEND_FIELDS: BackendFields = { + protocol: 'http', + host: '127.0.0.1', + port: '9090', +}; - if (normalizedBaseURL) { - const prefix = normalizedBaseURL.substring(0, 7); - if (prefix.includes(':/')) { - if (prefix !== 'http://' && prefix !== 'https:/') { - return { error: 'Must starts with http:// or https://' }; - } - } else if (currentProtocol) { - normalizedBaseURL = `${currentProtocol}//${normalizedBaseURL}`; - } - } +const Ok = 0; - return { baseURL: normalizedBaseURL }; +/** IPv6 在 URL 里带方括号,表单里只展示裸地址 */ +function stripBrackets(host: string) { + return host.replace(/^\[/, '').replace(/\]$/, ''); } -export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[number, string?]> { +function isIPv6(host: string) { + return host.includes(':'); +} + +/** 把完整 baseURL 拆成协议 / 主机 / 端口,解析失败返回 null */ +export function splitAPIBaseURL(baseURL: string): BackendFields | null { + let url: URL; try { - new URL(apiConfig.baseURL); + url = new URL(baseURL); } catch (e) { - if (apiConfig.baseURL) { - const prefix = apiConfig.baseURL.substring(0, 7); - if (prefix !== 'http://' && prefix !== 'https:/') { - return [1, 'Must starts with http:// or https://']; - } - } + return null; + } - return [1, 'Invalid URL']; + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + const protocol: Protocol = url.protocol === 'https:' ? 'https' : 'http'; + + return { + protocol, + host: stripBrackets(url.hostname), + port: url.port || (protocol === 'https' ? '443' : '80'), + }; +} + +/** + * 允许把一整条地址粘贴进 Host 输入框,自动拆到对应字段。 + * 返回 null 表示这就是个普通主机名,按原样填入 Host 即可。 + */ +export function splitPastedHost(value: string): Partial<BackendFields> | null { + const text = value.trim(); + const hasProtocol = text.includes('://'); + if (!hasProtocol && !/^[^\s/:]+:\d+$/.test(text)) return null; + + const fields = splitAPIBaseURL(hasProtocol ? text : `http://${text}`); + if (!fields) return null; + + // 没写协议时不要凭空替用户选一个 + return hasProtocol ? fields : { host: fields.host, port: fields.port }; +} + +/** 三段拼回 baseURL,同时做基本校验 */ +export function buildAPIBaseURL({ + protocol, + host, + port, +}: BackendFields): { baseURL: string } | { error: string } { + const trimmedHost = stripBrackets(host.trim()); + const trimmedPort = port.trim(); + + if (!trimmedHost) return { error: 'Host is required' }; + if (/[\s/?#]/.test(trimmedHost)) return { error: 'Invalid host' }; + if (!trimmedPort) return { error: 'Port is required' }; + if (!/^\d{1,5}$/.test(trimmedPort) || Number(trimmedPort) < 1 || Number(trimmedPort) > 65535) { + return { error: 'Port must be a number between 1 and 65535' }; } + const hostname = isIPv6(trimmedHost) ? `[${trimmedHost}]` : trimmedHost; + return { baseURL: `${protocol}://${hostname}:${trimmedPort}` }; +} + +export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[number, string?]> { try { const res = await fetchConfigs(apiConfig); if (res.status > 399) { @@ -50,6 +97,9 @@ export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[numbe export async function detectEmbeddedAPIBaseURL() { try { const res = await fetch('/'); + // 内核设置了 secret 时根路径返回 401,这同样说明当前 origin 就是 API 地址 + // (面板被内核自己托管在 /ui/ 下的常见情况) + if (res.status === 401) return window.location.origin; if (res.headers.get('content-type')?.includes('application/json')) { const data = await res.json(); if (data.hello === 'clash') { |
