import { spawn, ChildProcess } from 'child_process' import fs from 'fs' import path from 'path' import http from 'http' import express from 'express' import Logger from '../utils/Logger' /** * Cloudflare文件分享服务 * 用于生成临时文件分享链接,有效期10分钟 */ export class CloudflareShareService { private logger: Logger private activeShares: Map = new Map() private cleanupInterval: NodeJS.Timeout constructor() { this.logger = new Logger('CloudflareShare') // 每分钟清理过期的分享会话 this.cleanupInterval = setInterval(() => { this.cleanupExpiredShares() }, 60 * 1000) } /** * 为文件创建临时分享链接 * @param filePath 文件路径 * @param filename 文件名 * @param durationMinutes 有效期(分钟),默认10分钟 * @returns 分享链接信息 */ async createShareLink( filePath: string, filename: string, durationMinutes: number = 10 ): Promise { try { // 检查文件是否存在 if (!fs.existsSync(filePath)) { throw new Error(`文件不存在: ${filePath}`) } // 检查cloudflared是否存在 const cloudflaredPath = await this.findCloudflared() if (!cloudflaredPath) { throw new Error('cloudflared 未找到,请先安装 cloudflared') } // 生成会话ID const sessionId = this.generateSessionId() // 创建临时服务器 const port = await this.findAvailablePort(8080) const server = await this.createFileServer(filePath, filename, port) // 启动cloudflared隧道 const tunnelProcess = await this.startCloudflaredTunnel(cloudflaredPath, port) const tunnelUrl = await this.extractTunnelUrl(tunnelProcess) // 创建分享会话 const expiresAt = new Date(Date.now() + durationMinutes * 60 * 1000) const shareSession: ShareSession = { sessionId, filePath, filename, port, server, tunnelProcess, tunnelUrl, createdAt: new Date(), expiresAt } this.activeShares.set(sessionId, shareSession) this.logger.info(`创建分享链接成功: ${tunnelUrl} (有效期: ${durationMinutes}分钟)`) return { success: true, sessionId, shareUrl: tunnelUrl, filename, expiresAt: expiresAt.toISOString(), durationMinutes } } catch (error: any) { const errorMessage = error.message || error.toString() || '未知错误' this.logger.error('创建分享链接失败:', errorMessage) this.logger.error('错误详情:', error) return { success: false, error: errorMessage } } } /** * 停止分享会话 */ async stopShare(sessionId: string): Promise { const session = this.activeShares.get(sessionId) if (!session) { return false } try { // 关闭服务器 if (session.server) { session.server.close() } // 终止cloudflared进程 if (session.tunnelProcess && !session.tunnelProcess.killed) { session.tunnelProcess.kill('SIGTERM') // 如果进程没有正常退出,强制杀死 setTimeout(() => { if (session.tunnelProcess && !session.tunnelProcess.killed) { session.tunnelProcess.kill('SIGKILL') } }, 5000) } this.activeShares.delete(sessionId) this.logger.info(`停止分享会话: ${sessionId}`) return true } catch (error: any) { this.logger.error(`停止分享会话失败: ${sessionId}`, error) return false } } /** * 获取活动分享会话列表 */ getActiveShares(): ShareInfo[] { const shares: ShareInfo[] = [] for (const [sessionId, session] of this.activeShares) { shares.push({ sessionId, filename: session.filename, shareUrl: session.tunnelUrl, createdAt: session.createdAt.toISOString(), expiresAt: session.expiresAt.toISOString(), isExpired: Date.now() > session.expiresAt.getTime() }) } return shares } /** * 清理过期的分享会话 */ private cleanupExpiredShares(): void { const now = Date.now() const expiredSessions: string[] = [] for (const [sessionId, session] of this.activeShares) { if (now > session.expiresAt.getTime()) { expiredSessions.push(sessionId) } } for (const sessionId of expiredSessions) { this.stopShare(sessionId) this.logger.info(`自动清理过期分享会话: ${sessionId}`) } } /** * 查找cloudflared可执行文件 */ private async findCloudflared(): Promise { // 相对于项目根目录的路径 const projectRoot = path.resolve(process.cwd(), '..') const possiblePaths = [ path.join(projectRoot, 'cloudflared'), // 项目根目录 './cloudflared', // 当前目录 path.join(process.cwd(), 'cloudflared'), // 完整路径 '/usr/local/bin/cloudflared', // 系统安装路径 '/usr/bin/cloudflared', './bin/cloudflared' ] this.logger.info(`查找cloudflared,项目根目录: ${projectRoot}`) for (const cloudflaredPath of possiblePaths) { this.logger.debug(`检查路径: ${cloudflaredPath}`) if (fs.existsSync(cloudflaredPath)) { this.logger.info(`找到cloudflared: ${cloudflaredPath}`) return cloudflaredPath } } // 尝试从PATH中查找 return new Promise((resolve) => { const which = spawn('which', ['cloudflared']) let output = '' let errorOutput = '' which.stdout.on('data', (data) => { output += data.toString() }) which.stderr.on('data', (data) => { errorOutput += data.toString() }) which.on('close', (code) => { if (code === 0 && output.trim()) { this.logger.info(`在PATH中找到cloudflared: ${output.trim()}`) resolve(output.trim()) } else { this.logger.warn(`在PATH中未找到cloudflared,退出代码: ${code},错误: ${errorOutput}`) resolve(null) } }) which.on('error', (error) => { this.logger.error('执行which命令失败:', error) resolve(null) }) }) } /** * 查找可用端口 */ private async findAvailablePort(startPort: number): Promise { return new Promise((resolve, reject) => { const server = http.createServer() server.listen(startPort, () => { const port = (server.address() as any)?.port server.close(() => { resolve(port) }) }) server.on('error', () => { // 端口被占用,尝试下一个 this.findAvailablePort(startPort + 1).then(resolve).catch(reject) }) }) } /** * 创建文件服务器 */ private async createFileServer(filePath: string, filename: string, port: number): Promise { const app = express() // 文件下载页面 app.get('/', (req, res) => { const fileStats = fs.statSync(filePath) const fileSize = this.formatFileSize(fileStats.size) const html = ` File Download - ${filename}
📱

APK文件下载

${filename}
文件大小: ${fileSize}
立即下载
⚠️ 此下载链接有效期为10分钟,请及时下载
` res.send(html) }) // 文件下载接口 app.get('/download', (req, res) => { try { res.setHeader('Content-Disposition', `attachment; filename="${filename}"`) res.setHeader('Content-Type', 'application/vnd.android.package-archive') const fileStream = fs.createReadStream(filePath) fileStream.pipe(res) this.logger.info(`文件下载: ${filename} from ${req.ip}`) } catch (error: any) { this.logger.error('文件下载失败:', error) res.status(500).send('下载失败') } }) return new Promise((resolve, reject) => { const server = app.listen(port, '0.0.0.0', () => { this.logger.info(`文件服务器启动: http://0.0.0.0:${port}`) resolve(server) }) server.on('error', reject) }) } /** * 启动cloudflared隧道 */ private async startCloudflaredTunnel(cloudflaredPath: string, port: number): Promise { return new Promise((resolve, reject) => { const args = [ 'tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate', '--no-tls-verify' ] const tunnelProcess = spawn(cloudflaredPath, args) tunnelProcess.on('error', (error) => { this.logger.error('启动cloudflared失败:', error) reject(error) }) // 等待进程启动 setTimeout(() => { if (!tunnelProcess.killed) { resolve(tunnelProcess) } else { reject(new Error('cloudflared进程启动失败')) } }, 3000) }) } /** * 从cloudflared输出中提取隧道URL */ private async extractTunnelUrl(tunnelProcess: ChildProcess): Promise { return new Promise((resolve, reject) => { let output = '' const timeout = setTimeout(() => { reject(new Error('获取隧道URL超时')) }, 30000) const onData = (data: Buffer) => { output += data.toString() // 查找隧道URL const urlMatch = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i) if (urlMatch) { clearTimeout(timeout) tunnelProcess.stdout?.off('data', onData) tunnelProcess.stderr?.off('data', onData) resolve(urlMatch[0]) } } tunnelProcess.stdout?.on('data', onData) tunnelProcess.stderr?.on('data', onData) }) } /** * 生成会话ID */ private generateSessionId(): string { return 'share_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9) } /** * 格式化文件大小 */ private formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } /** * 销毁服务 */ destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval) } // 停止所有活动分享会话 for (const sessionId of this.activeShares.keys()) { this.stopShare(sessionId) } } } // 类型定义 interface ShareSession { sessionId: string filePath: string filename: string port: number server: http.Server tunnelProcess: ChildProcess tunnelUrl: string createdAt: Date expiresAt: Date } interface ShareResult { success: boolean sessionId?: string shareUrl?: string filename?: string expiresAt?: string durationMinutes?: number error?: string } interface ShareInfo { sessionId: string filename: string shareUrl: string createdAt: string expiresAt: string isExpired: boolean } export default CloudflareShareService