diff --git a/packages/napcat-common/package.json b/packages/napcat-common/package.json index c26b39dd..ddbf1184 100644 --- a/packages/napcat-common/package.json +++ b/packages/napcat-common/package.json @@ -17,8 +17,7 @@ }, "dependencies": { "ajv": "^8.13.0", - "file-type": "^21.0.0", - "undici": "^6.19.0" + "file-type": "^21.0.0" }, "devDependencies": { "@types/node": "^22.0.1" diff --git a/packages/napcat-common/src/file.ts b/packages/napcat-common/src/file.ts index 79e65aa1..5d51e5ae 100644 --- a/packages/napcat-common/src/file.ts +++ b/packages/napcat-common/src/file.ts @@ -2,6 +2,8 @@ import fs from 'fs'; import { stat } from 'fs/promises'; import crypto, { randomUUID } from 'crypto'; import path from 'node:path'; +import http from 'node:http'; +import tls from 'node:tls'; import { solveProblem } from '@/napcat-common/src/helper'; export interface HttpDownloadOptions { @@ -122,15 +124,8 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b // 如果配置了代理,使用代理下载 if (proxy) { try { - // Node.js 18+ 内置 undici,使用动态导入 - const undici = await import('undici'); - const dispatcher = new undici.ProxyAgent(proxy); - const response = await undici.fetch(url, { - headers, - redirect: 'follow', - dispatcher, - }); - return response as unknown as Response; + const response = await httpDownloadWithProxy(url, headers, proxy); + return new Response(response, { status: 200, statusText: 'OK' }); } catch (proxyError) { // 如果代理失败,记录错误并尝试直接下载 console.error('代理下载失败,尝试直接下载:', proxyError); @@ -146,6 +141,220 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b return fetchRes; } +/** + * 使用 HTTP/HTTPS 代理下载文件 + */ +function httpDownloadWithProxy (url: string, headers: Record, proxy: string): Promise { + return new Promise((resolve, reject) => { + const targetUrl = new URL(url); + const proxyUrl = new URL(proxy); + + const isTargetHttps = targetUrl.protocol === 'https:'; + const proxyPort = parseInt(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80); + + // 代理认证头 + const proxyAuthHeader = proxyUrl.username && proxyUrl.password + ? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64') } + : {}; + + if (isTargetHttps) { + // HTTPS 目标:需要通过 CONNECT 建立隧道 + const connectReq = http.request({ + host: proxyUrl.hostname, + port: proxyPort, + method: 'CONNECT', + path: `${targetUrl.hostname}:${targetUrl.port || 443}`, + headers: { + 'Host': `${targetUrl.hostname}:${targetUrl.port || 443}`, + ...proxyAuthHeader, + }, + }); + + connectReq.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`代理 CONNECT 失败: ${res.statusCode} ${res.statusMessage}`)); + return; + } + + // 在隧道上建立 TLS 连接 + const tlsSocket = tls.connect({ + socket: socket, + servername: targetUrl.hostname, + rejectUnauthorized: true, + }, () => { + // TLS 握手成功,发送 HTTP 请求 + const requestPath = targetUrl.pathname + targetUrl.search; + const requestHeaders = { + ...headers, + 'Host': targetUrl.hostname, + 'Connection': 'close', + }; + + const headerLines = Object.entries(requestHeaders) + .map(([key, value]) => `${key}: ${value}`) + .join('\r\n'); + + const httpRequest = `GET ${requestPath} HTTP/1.1\r\n${headerLines}\r\n\r\n`; + tlsSocket.write(httpRequest); + }); + + // 解析 HTTP 响应 + let responseData = Buffer.alloc(0); + let headersParsed = false; + let statusCode = 0; + let isChunked = false; + let bodyData = Buffer.alloc(0); + let redirectLocation: string | null = null; + + tlsSocket.on('data', (chunk: Buffer) => { + responseData = Buffer.concat([responseData, chunk]); + + if (!headersParsed) { + const headerEndIndex = responseData.indexOf('\r\n\r\n'); + if (headerEndIndex !== -1) { + headersParsed = true; + const headerStr = responseData.subarray(0, headerEndIndex).toString(); + const headerLines = headerStr.split('\r\n'); + + // 解析状态码 + const statusLine = headerLines[0]; + const statusMatch = statusLine?.match(/HTTP\/\d\.\d\s+(\d+)/); + statusCode = statusMatch ? parseInt(statusMatch[1]!) : 0; + + // 解析响应头 + for (const line of headerLines.slice(1)) { + const [key, ...valueParts] = line.split(':'); + const value = valueParts.join(':').trim(); + if (key?.toLowerCase() === 'transfer-encoding' && value.toLowerCase() === 'chunked') { + isChunked = true; + } else if (key?.toLowerCase() === 'location') { + redirectLocation = value; + } + } + + bodyData = responseData.subarray(headerEndIndex + 4); + } + } else { + bodyData = Buffer.concat([bodyData, chunk]); + } + }); + + tlsSocket.on('end', () => { + // 处理重定向 + if (statusCode >= 300 && statusCode < 400 && redirectLocation) { + const redirectUrl = redirectLocation.startsWith('http') + ? redirectLocation + : `${targetUrl.protocol}//${targetUrl.host}${redirectLocation}`; + httpDownloadWithProxy(redirectUrl, headers, proxy).then(resolve).catch(reject); + return; + } + + if (statusCode !== 200) { + reject(new Error(`下载失败: ${statusCode}`)); + return; + } + + // 处理 chunked 编码 + if (isChunked) { + resolve(parseChunkedBody(bodyData)); + } else { + resolve(bodyData); + } + }); + + tlsSocket.on('error', (err) => { + reject(new Error(`TLS 连接错误: ${err.message}`)); + }); + }); + + connectReq.on('error', (err) => { + reject(new Error(`代理连接错误: ${err.message}`)); + }); + + connectReq.end(); + } else { + // HTTP 目标:直接通过代理请求 + const req = http.request({ + host: proxyUrl.hostname, + port: proxyPort, + method: 'GET', + path: url, // 完整 URL + headers: { + ...headers, + 'Host': targetUrl.hostname, + ...proxyAuthHeader, + }, + }, (response) => { + handleResponse(response, resolve, reject, url, headers, proxy); + }); + + req.on('error', (err) => { + reject(new Error(`代理请求错误: ${err.message}`)); + }); + + req.end(); + } + }); +} + +/** + * 解析 chunked 编码的响应体 + */ +function parseChunkedBody (data: Buffer): Buffer { + const chunks: Buffer[] = []; + let offset = 0; + + while (offset < data.length) { + // 查找 chunk 大小行的结束 + const lineEnd = data.indexOf('\r\n', offset); + if (lineEnd === -1) break; + + const sizeStr = data.subarray(offset, lineEnd).toString().split(';')[0]; // 忽略 chunk 扩展 + const chunkSize = parseInt(sizeStr!, 16); + + if (chunkSize === 0) break; // 最后一个 chunk + + const chunkStart = lineEnd + 2; + const chunkEnd = chunkStart + chunkSize; + + if (chunkEnd > data.length) break; + + chunks.push(data.subarray(chunkStart, chunkEnd)); + offset = chunkEnd + 2; // 跳过 chunk 数据后的 \r\n + } + + return Buffer.concat(chunks); +} + +/** + * 处理 HTTP 响应 + */ +function handleResponse ( + response: http.IncomingMessage, + resolve: (value: Buffer) => void, + reject: (reason: Error) => void, + _url: string, + headers: Record, + proxy: string +): void { + // 处理重定向 + if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + httpDownloadWithProxy(response.headers.location, headers, proxy).then(resolve).catch(reject); + return; + } + + if (response.statusCode !== 200) { + reject(new Error(`下载失败: ${response.statusCode} ${response.statusMessage}`)); + return; + } + + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks))); + response.on('error', reject); +} + export async function httpDownload (options: string | HttpDownloadOptions): Promise { const useReferer = typeof options === 'string'; let resp = await tryDownload(options);