Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions server/api/web/proxy/download.get.ts
Original file line number Diff line number Diff line change
@@ -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<DownloadProxyQuery>(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),
});
});
85 changes: 85 additions & 0 deletions server/utils/download-proxy.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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;
}
112 changes: 112 additions & 0 deletions test/download_proxy_list.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

constructor(values: Record<string, string> = {}) {
for (const [key, value] of Object.entries(values)) {
this.values.set(key, value);
}
}

getItem(key: string) {
return this.values.get(key) ?? null;
}
}

function withBrowserContext<T>(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();
21 changes: 21 additions & 0 deletions test/download_proxy_target.ts
Original file line number Diff line number Diff line change
@@ -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();
8 changes: 2 additions & 6 deletions utils/download/BaseDownloader.ts
Original file line number Diff line number Diff line change
@@ -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<ParsedCredential[]>('auto-detect-credentials:credentials', []);
Expand Down Expand Up @@ -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();
Expand Down
67 changes: 67 additions & 0 deletions utils/download/proxy-list.ts
Original file line number Diff line number Diff line change
@@ -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')));
}
17 changes: 3 additions & 14 deletions utils/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
* 代理实例
Expand Down Expand Up @@ -171,7 +171,7 @@ class ProxyPool {
}

// 代理池
export const pool = new ProxyPool(PUBLIC_PROXY_LIST);
export const pool = new ProxyPool(resolveDownloadProxyList());

/**
* 使用代理 proxy 下载资源
Expand Down Expand Up @@ -285,19 +285,8 @@ export async function downloads<T extends DownloadResource>(
downloadFn: DownloadFn<T>,
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 });

Expand Down