mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
fix: fetch remote subscriptions with axios
This commit is contained in:
@@ -7,7 +7,7 @@ import { randomBytes } from 'crypto'
|
||||
import { tmpdir } from 'os'
|
||||
import { app } from 'electron'
|
||||
import i18next from 'i18next'
|
||||
import * as chromeRequest from '../utils/chromeRequest'
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
import { parse, stringify } from '../utils/yaml'
|
||||
import { defaultProfile } from '../utils/template'
|
||||
import { decryptAgeContent } from '../utils/age'
|
||||
@@ -271,8 +271,53 @@ interface FetchResult {
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647
|
||||
const MAX_PROFILE_INTERVAL_MINUTES = Math.floor(MAX_TIMER_DELAY_MS / (60 * 1000))
|
||||
|
||||
function redactSubscriptionUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
if (urlObj.username) urlObj.username = '***'
|
||||
if (urlObj.password) urlObj.password = '***'
|
||||
if (urlObj.search) urlObj.search = '?***'
|
||||
return urlObj.toString()
|
||||
} catch {
|
||||
return url.includes('?') ? `${url.split('?')[0]}?***` : url
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAxiosHeaders(headers: AxiosResponse['headers']): Record<string, string> {
|
||||
const normalized: Record<string, string> = {}
|
||||
Object.entries(headers as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
normalized[key.toLowerCase()] = value.join(', ')
|
||||
} else if (value !== undefined) {
|
||||
normalized[key.toLowerCase()] = String(value)
|
||||
}
|
||||
})
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parsedProfileSummary(parsed: Record<string, unknown>): string {
|
||||
const topKeys = Object.keys(parsed).slice(0, 20)
|
||||
const proxies = parsed['proxies']
|
||||
const proxyProviders = parsed['proxy-providers']
|
||||
const proxyCount = Array.isArray(proxies) ? proxies.length : undefined
|
||||
const providerCount =
|
||||
proxyProviders && typeof proxyProviders === 'object'
|
||||
? Object.keys(proxyProviders).length
|
||||
: undefined
|
||||
|
||||
return JSON.stringify({
|
||||
topKeys,
|
||||
hasProxies: Boolean(proxies),
|
||||
hasProxyProviders: Boolean(proxyProviders),
|
||||
proxyCount,
|
||||
providerCount
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchAndValidateSubscription(options: FetchOptions): Promise<FetchResult> {
|
||||
const { url, useProxy, mixedPort, userAgent, authToken, timeout, substore } = options
|
||||
const redactedUrl = redactSubscriptionUrl(url)
|
||||
const fetchMode = substore ? 'substore' : useProxy ? 'proxy' : 'direct'
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': userAgent,
|
||||
@@ -280,38 +325,86 @@ async function fetchAndValidateSubscription(options: FetchOptions): Promise<Fetc
|
||||
}
|
||||
if (authToken) headers['Authorization'] = authToken
|
||||
|
||||
let res: chromeRequest.Response<string>
|
||||
await profileLogger.info(
|
||||
`Fetching remote profile url=${redactedUrl} mode=${fetchMode} timeout=${timeout}ms auth=${authToken ? 'yes' : 'no'}`
|
||||
)
|
||||
|
||||
let requestUrl = url
|
||||
let proxy:
|
||||
| {
|
||||
protocol: 'http'
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
| false = false
|
||||
|
||||
if (substore) {
|
||||
const urlObj = new URL(`http://127.0.0.1:${subStorePort}${url}`)
|
||||
urlObj.searchParams.set('target', 'ClashMeta')
|
||||
urlObj.searchParams.set('noCache', 'true')
|
||||
if (useProxy) {
|
||||
if (useProxy && mixedPort !== 0) {
|
||||
urlObj.searchParams.set('proxy', `http://127.0.0.1:${mixedPort}`)
|
||||
}
|
||||
res = await chromeRequest.get(urlObj.toString(), { headers, responseType: 'text', timeout })
|
||||
} else {
|
||||
res = await chromeRequest.get(url, {
|
||||
requestUrl = urlObj.toString()
|
||||
} else if (useProxy && mixedPort !== 0) {
|
||||
proxy = { protocol: 'http', host: '127.0.0.1', port: mixedPort }
|
||||
}
|
||||
|
||||
let res: AxiosResponse<string>
|
||||
try {
|
||||
res = await axios.get<string>(requestUrl, {
|
||||
headers,
|
||||
responseType: 'text',
|
||||
timeout,
|
||||
proxy: useProxy ? { protocol: 'http', host: '127.0.0.1', port: mixedPort } : false
|
||||
proxy,
|
||||
validateStatus: () => true,
|
||||
transformResponse: [(data) => data]
|
||||
})
|
||||
} catch (error) {
|
||||
await profileLogger.warn(
|
||||
`Remote profile request failed url=${redactedUrl} mode=${fetchMode}`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = typeof res.data === 'string' ? res.data : String(res.data ?? '')
|
||||
const responseHeaders = normalizeAxiosHeaders(res.headers)
|
||||
|
||||
await profileLogger.info(
|
||||
`Remote profile response url=${redactedUrl} mode=${fetchMode} status=${res.status} contentType=${String(
|
||||
responseHeaders['content-type'] || ''
|
||||
)} bytes=${Buffer.byteLength(data, 'utf8')}`
|
||||
)
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await profileLogger.warn(
|
||||
`Remote profile request rejected url=${redactedUrl} mode=${fetchMode} status=${res.status}`
|
||||
)
|
||||
throw new Error(`Subscription failed: Request status code ${res.status}`)
|
||||
}
|
||||
|
||||
const decryptedData = await decryptAgeContent(res.data, options.ageSecretKey, 'subscription')
|
||||
const decryptedData = await decryptAgeContent(data, options.ageSecretKey, 'subscription')
|
||||
const parsed = parse(decryptedData) as Record<string, unknown> | null
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
await profileLogger.warn(
|
||||
`Remote profile parse failed url=${redactedUrl} mode=${fetchMode} parsedType=${typeof parsed}`
|
||||
)
|
||||
throw new Error('Subscription failed: Profile is not a valid YAML')
|
||||
}
|
||||
await profileLogger.info(
|
||||
`Remote profile parsed url=${redactedUrl} mode=${fetchMode} summary=${parsedProfileSummary(parsed)}`
|
||||
)
|
||||
if (!parsed['proxies'] && !parsed['proxy-providers']) {
|
||||
await profileLogger.warn(
|
||||
`Remote profile validation failed url=${redactedUrl} mode=${fetchMode} reason=missing-proxies-or-providers summary=${parsedProfileSummary(
|
||||
parsed
|
||||
)}`
|
||||
)
|
||||
throw new Error('Subscription failed: Profile missing proxies or providers')
|
||||
}
|
||||
|
||||
return { data: res.data, headers: res.headers }
|
||||
return { data, headers: responseHeaders }
|
||||
}
|
||||
|
||||
export async function createProfile(item: Partial<IProfileItem>): Promise<IProfileItem> {
|
||||
@@ -344,9 +437,19 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
|
||||
if (!item.url) throw new Error('Empty URL')
|
||||
|
||||
const profileUrl = item.url
|
||||
await profileLogger.info(
|
||||
`Creating/updating remote profile id=${id} name=${newItem.name} url=${redactSubscriptionUrl(
|
||||
profileUrl
|
||||
)} useProxy=${newItem.useProxy} substore=${newItem.substore}`
|
||||
)
|
||||
const dedupKey = `${id}::${profileUrl}`
|
||||
const existing = inflightRemoteFetches.get(dedupKey)
|
||||
if (existing) return existing
|
||||
if (existing) {
|
||||
await profileLogger.info(
|
||||
`Remote profile fetch deduplicated id=${id} url=${redactSubscriptionUrl(profileUrl)}`
|
||||
)
|
||||
return existing
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<IProfileItem> => {
|
||||
const { userAgent, subscriptionTimeout = 30000 } = await getAppConfig()
|
||||
@@ -375,6 +478,12 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
|
||||
try {
|
||||
result = await fetchSub(false, userItemTimeoutMs)
|
||||
} catch (directError) {
|
||||
await profileLogger.warn(
|
||||
`Direct remote profile fetch failed id=${id} url=${redactSubscriptionUrl(
|
||||
profileUrl
|
||||
)}; trying proxy fallback`,
|
||||
directError
|
||||
)
|
||||
try {
|
||||
// smart fallback
|
||||
result = await fetchSub(true, subscriptionTimeout)
|
||||
@@ -403,6 +512,11 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
|
||||
}
|
||||
|
||||
await setProfileStr(id, data)
|
||||
await profileLogger.info(
|
||||
`Remote profile saved id=${id} name=${newItem.name} path=${profilePath(
|
||||
id
|
||||
)} bytes=${Buffer.byteLength(data || '', 'utf8')}`
|
||||
)
|
||||
return newItem
|
||||
})()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user