diff --git a/server/api/web/proxy/download.get.ts b/server/api/web/proxy/download.get.ts new file mode 100644 index 00000000..66065fec --- /dev/null +++ b/server/api/web/proxy/download.get.ts @@ -0,0 +1,43 @@ +import { + buildDownloadProxyHeaders, + buildDownloadProxyResponseHeaders, + validateDownloadTargetUrl, +} from '~/server/utils/download-proxy'; + +interface DownloadProxyQuery { + url?: string; + headers?: string; +} + +export default defineEventHandler(async event => { + const query = getQuery(event); + if (!query.url) { + throw createError({ + statusCode: 400, + statusMessage: 'url 不能为空', + }); + } + + const targetUrl = validateDownloadTargetUrl(query.url); + const requestHeaders = buildDownloadProxyHeaders(query.headers); + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(targetUrl, { + headers: requestHeaders, + redirect: 'follow', + }); + } catch (error) { + throw createError({ + statusCode: 502, + statusMessage: '下载代理请求失败', + message: error instanceof Error ? error.message : String(error), + }); + } + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: buildDownloadProxyResponseHeaders(upstreamResponse.headers), + }); +}); diff --git a/server/utils/download-proxy.ts b/server/utils/download-proxy.ts new file mode 100644 index 00000000..faad1f85 --- /dev/null +++ b/server/utils/download-proxy.ts @@ -0,0 +1,85 @@ +import { createError } from 'h3'; +import { USER_AGENT } from '../../config'; + +const ALLOWED_DOWNLOAD_HOSTS = new Set([ + 'mp.weixin.qq.com', + 'mmbiz.qpic.cn', + 'mmbiz.qlogo.cn', + 'wx.qlogo.cn', + 'res.wx.qq.com', + 'wxa.wxs.qq.com', +]); + +export function normalizeDownloadTargetUrl(url: string): URL { + const trimmed = url.trim(); + const normalized = trimmed.startsWith('//') ? `https:${trimmed}` : trimmed; + return new URL(normalized); +} + +export function validateDownloadTargetUrl(url: string): URL { + const targetUrl = normalizeDownloadTargetUrl(url); + + if (!['http:', 'https:'].includes(targetUrl.protocol)) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + message: '只支持 HTTP/HTTPS 下载链接', + }); + } + + if (!ALLOWED_DOWNLOAD_HOSTS.has(targetUrl.hostname)) { + throw createError({ + statusCode: 400, + statusMessage: 'Bad Request', + message: `不允许代理该域名: ${targetUrl.hostname}`, + }); + } + + return targetUrl; +} + +export function buildDownloadProxyHeaders(headersParam?: string): Headers { + const headers = new Headers({ + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + Referer: 'https://mp.weixin.qq.com/', + Origin: 'https://mp.weixin.qq.com', + 'User-Agent': USER_AGENT, + }); + + if (!headersParam) { + return headers; + } + + try { + const customHeaders = JSON.parse(headersParam) as Record; + for (const [key, value] of Object.entries(customHeaders)) { + if (!value) { + continue; + } + if (key.toLowerCase() === 'cookie') { + headers.set('Cookie', value); + } + } + } catch { + // Ignore malformed optional headers and keep the default request headers. + } + + return headers; +} + +export function buildDownloadProxyResponseHeaders(upstreamHeaders: Headers): Headers { + const headers = new Headers(); + const contentType = upstreamHeaders.get('content-type'); + const cacheControl = upstreamHeaders.get('cache-control'); + const etag = upstreamHeaders.get('etag'); + const lastModified = upstreamHeaders.get('last-modified'); + + headers.set('Access-Control-Allow-Origin', '*'); + if (contentType) headers.set('Content-Type', contentType); + if (cacheControl) headers.set('Cache-Control', cacheControl); + if (etag) headers.set('ETag', etag); + if (lastModified) headers.set('Last-Modified', lastModified); + + return headers; +} diff --git a/test/download_proxy_list.ts b/test/download_proxy_list.ts new file mode 100644 index 00000000..abf111ba --- /dev/null +++ b/test/download_proxy_list.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { PUBLIC_PROXY_LIST } from '../config/public-proxy'; +import { + LOCAL_DOWNLOAD_PROXY, + resolveDownloadProxyList, + resolveDownloadProxyListFromLocalStorage, +} from '../utils/download/proxy-list'; + +class MemoryStorage { + private readonly values = new Map(); + + constructor(values: Record = {}) { + for (const [key, value] of Object.entries(values)) { + this.values.set(key, value); + } + } + + getItem(key: string) { + return this.values.get(key) ?? null; + } +} + +function withBrowserContext(callback: () => T): T { + const originalWindow = globalThis.window; + + try { + (globalThis as unknown as { window: { location: { protocol: string } } }).window = { + location: { + protocol: 'http:', + }, + }; + + return callback(); + } finally { + if (originalWindow === undefined) { + delete (globalThis as unknown as { window?: unknown }).window; + } else { + (globalThis as unknown as { window: unknown }).window = originalWindow; + } + } +} + +function run() { + const privateProxies = ['https://example.test/proxy']; + + // Private proxies always take priority + assert.deepEqual(resolveDownloadProxyList(privateProxies), privateProxies); + + // In Node context (no window), no private proxies → PUBLIC_PROXY_LIST as fallback + assert.deepEqual(resolveDownloadProxyList([]), PUBLIC_PROXY_LIST); + + // No mutation of return value + const defaultProxies = resolveDownloadProxyList([]); + defaultProxies.push('https://mutated.example.test'); + assert.deepEqual(resolveDownloadProxyList([]), PUBLIC_PROXY_LIST); + + // Empty localStorage → PUBLIC_PROXY_LIST (Node context) + assert.deepEqual(resolveDownloadProxyListFromLocalStorage(new MemoryStorage()), PUBLIC_PROXY_LIST); + + withBrowserContext(() => { + assert.deepEqual(resolveDownloadProxyList([]), [LOCAL_DOWNLOAD_PROXY]); + assert.deepEqual(resolveDownloadProxyList(PUBLIC_PROXY_LIST), [LOCAL_DOWNLOAD_PROXY]); + assert.deepEqual(resolveDownloadProxyList(PUBLIC_PROXY_LIST.slice(0, 2)), [LOCAL_DOWNLOAD_PROXY]); + assert.deepEqual(resolveDownloadProxyList([PUBLIC_PROXY_LIST[0], 'https://private.example.test']), [ + 'https://private.example.test', + ]); + + assert.deepEqual( + resolveDownloadProxyListFromLocalStorage( + new MemoryStorage({ + preferences: JSON.stringify({ + privateProxyList: PUBLIC_PROXY_LIST, + }), + }) + ), + [LOCAL_DOWNLOAD_PROXY] + ); + + assert.deepEqual( + resolveDownloadProxyListFromLocalStorage( + new MemoryStorage({ + 'wechat-proxy': JSON.stringify(PUBLIC_PROXY_LIST), + }) + ), + [LOCAL_DOWNLOAD_PROXY] + ); + }); + + // preferences with privateProxyList → those proxies (trimmed) + assert.deepEqual( + resolveDownloadProxyListFromLocalStorage( + new MemoryStorage({ + preferences: JSON.stringify({ + privateProxyList: ['https://private.example.test/', ' https://second.example.test '], + }), + }) + ), + ['https://private.example.test', 'https://second.example.test'] + ); + + // Legacy wechat-proxy key + assert.deepEqual( + resolveDownloadProxyListFromLocalStorage( + new MemoryStorage({ + 'wechat-proxy': JSON.stringify(['https://legacy.example.test']), + }) + ), + ['https://legacy.example.test'] + ); +} + +run(); diff --git a/test/download_proxy_target.ts b/test/download_proxy_target.ts new file mode 100644 index 00000000..a132278c --- /dev/null +++ b/test/download_proxy_target.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { + buildDownloadProxyHeaders, + normalizeDownloadTargetUrl, + validateDownloadTargetUrl, +} from '../server/utils/download-proxy'; + +function run() { + assert.equal(normalizeDownloadTargetUrl('//mmbiz.qpic.cn/demo.png').toString(), 'https://mmbiz.qpic.cn/demo.png'); + assert.equal(validateDownloadTargetUrl('https://mp.weixin.qq.com/s/demo').hostname, 'mp.weixin.qq.com'); + assert.equal(validateDownloadTargetUrl('https://mmbiz.qpic.cn/demo.png').hostname, 'mmbiz.qpic.cn'); + + assert.throws(() => validateDownloadTargetUrl('http://127.0.0.1:3000/secret'), /不允许代理/); + assert.throws(() => validateDownloadTargetUrl('ftp://mp.weixin.qq.com/demo'), /只支持/); + + const headers = buildDownloadProxyHeaders('{"cookie":"pass_ticket=a;wap_sid2=b"}'); + assert.equal(headers.get('Cookie'), 'pass_ticket=a;wap_sid2=b'); + assert.equal(headers.get('Referer'), 'https://mp.weixin.qq.com/'); +} + +run(); diff --git a/utils/download/BaseDownloader.ts b/utils/download/BaseDownloader.ts index 92b3a4e2..3b9cd7fa 100644 --- a/utils/download/BaseDownloader.ts +++ b/utils/download/BaseDownloader.ts @@ -1,11 +1,11 @@ import { sleep, timeout } from '#shared/utils/helpers'; import usePreferences from '~/composables/usePreferences'; -import { PUBLIC_PROXY_LIST } from '~/config/public-proxy'; import type { ParsedCredential } from '~/types/credential'; import type { Preferences } from '~/types/preferences'; import { bestConcurrencyCount } from '~/utils'; import { DEFAULT_OPTIONS } from './constants'; import { ProxyManager } from './ProxyManager'; +import { resolveDownloadProxyList } from './proxy-list'; import type { Callback, DownloaderStatus, DownloadOptions } from './types'; const credentials = useLocalStorage('auto-detect-credentials:credentials', []); @@ -33,11 +33,7 @@ export class BaseDownloader { constructor(urls: string[], options: DownloadOptions = {}) { this.validateInputs(urls); - const proxies = (preferences.value as Preferences).privateProxyList || []; - if (proxies.length === 0) { - // 如果没有配置私有代理,则使用公共代理 - proxies.push(...PUBLIC_PROXY_LIST); - } + const proxies = resolveDownloadProxyList((preferences.value as Preferences).privateProxyList || []); this.urls = [...urls].reverse(); this.pending = new Set(); diff --git a/utils/download/proxy-list.ts b/utils/download/proxy-list.ts new file mode 100644 index 00000000..ef42e466 --- /dev/null +++ b/utils/download/proxy-list.ts @@ -0,0 +1,67 @@ +import { PUBLIC_PROXY_LIST } from '../../config/public-proxy'; + +export const LOCAL_DOWNLOAD_PROXY = '/api/web/proxy/download'; + +function normalizeProxyUrl(proxy: string): string { + return proxy.trim().replace(/\/+$/, ''); +} + +const PUBLIC_PROXY_SET = new Set(PUBLIC_PROXY_LIST.map(normalizeProxyUrl)); + +function normalizePrivateProxyList(privateProxyList: string[]): string[] { + return privateProxyList.map(normalizeProxyUrl).filter(proxy => proxy && !PUBLIC_PROXY_SET.has(proxy)); +} + +export function isBrowserContext(): boolean { + return typeof window !== 'undefined' && window.location.protocol !== 'file:'; +} + +export function resolveDownloadProxyList(privateProxyList: string[] = []): string[] { + const normalizedPrivateProxies = normalizePrivateProxyList(privateProxyList); + + if (normalizedPrivateProxies.length > 0) { + return normalizedPrivateProxies; + } + + if (isBrowserContext()) { + return [LOCAL_DOWNLOAD_PROXY]; + } + + return [...PUBLIC_PROXY_LIST]; +} + +interface ProxyStorage { + getItem(key: string): string | null; +} + +function readStringArray(value: string | null): string[] { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + } catch { + return []; + } +} + +export function resolveDownloadProxyListFromLocalStorage(storage: ProxyStorage): string[] { + const preferencesValue = storage.getItem('preferences'); + if (preferencesValue) { + try { + const preferences = JSON.parse(preferencesValue) as { privateProxyList?: unknown }; + if (Array.isArray(preferences.privateProxyList)) { + const privateProxyList = preferences.privateProxyList.filter( + (item): item is string => typeof item === 'string' + ); + return resolveDownloadProxyList(privateProxyList); + } + } catch { + // Fall back to the legacy key below. + } + } + + return resolveDownloadProxyList(readStringArray(storage.getItem('wechat-proxy'))); +} diff --git a/utils/pool.ts b/utils/pool.ts index b9995465..2df8a50d 100644 --- a/utils/pool.ts +++ b/utils/pool.ts @@ -2,9 +2,9 @@ import dayjs from 'dayjs'; import PQueue from 'p-queue'; import { v4 as uuid } from 'uuid'; import { sleep } from '#shared/utils/helpers'; -import { PUBLIC_PROXY_LIST } from '~/config/public-proxy'; import type { DownloadableArticle } from '~/types/types'; import type { AudioResource, VideoResource } from '~/types/video'; +import { resolveDownloadProxyList, resolveDownloadProxyListFromLocalStorage } from '~/utils/download/proxy-list'; /** * 代理实例 @@ -171,7 +171,7 @@ class ProxyPool { } // 代理池 -export const pool = new ProxyPool(PUBLIC_PROXY_LIST); +export const pool = new ProxyPool(resolveDownloadProxyList()); /** * 使用代理 proxy 下载资源 @@ -285,19 +285,8 @@ export async function downloads( downloadFn: DownloadFn, useProxy = true ) { - // 检查是否设置了私有代理地址 - const privateProxy: string[] = []; - try { - const proxy = JSON.parse(window.localStorage.getItem('wechat-proxy')!); - if (Array.isArray(proxy) && proxy.length > 0) { - privateProxy.push(...proxy); - } - } catch (e) { - console.log(e); - } - // 初始化 pool - pool.init(privateProxy); + pool.init(resolveDownloadProxyListFromLocalStorage(window.localStorage)); const queue = new PQueue({ concurrency: pool.proxies.length });