fix: fetch remote subscriptions with axios

This commit is contained in:
Memory
2026-06-14 22:42:26 +08:00
committed by GitHub
parent 52208b9396
commit 7ff9338387

View File

@@ -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
})()