Files
server/src/services/CloudflareShareService.ts
wdvipa 450367dea2 111
2026-02-09 16:34:01 +08:00

506 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, ShareSession> = 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<ShareResult> {
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<boolean> {
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<string | null> {
// 相对于项目根目录的路径
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<number> {
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<http.Server> {
const app = express()
// 文件下载页面
app.get('/', (req, res) => {
const fileStats = fs.statSync(filePath)
const fileSize = this.formatFileSize(fileStats.size)
const html = `
<!DOCTYPE html>
<html>
<head>
<title>File Download - ${filename}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
max-width: 500px;
width: 100%;
}
.icon {
font-size: 48px;
margin-bottom: 20px;
color: #667eea;
}
h1 { color: #333; margin-bottom: 10px; font-size: 24px; }
.filename {
color: #666;
margin-bottom: 8px;
font-size: 18px;
word-break: break-all;
background: #f5f5f5;
padding: 12px;
border-radius: 8px;
}
.filesize {
color: #888;
margin-bottom: 30px;
font-size: 14px;
}
.download-btn {
display: inline-block;
padding: 16px 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
transition: transform 0.2s, box-shadow 0.2s;
border: none;
cursor: pointer;
}
.download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
}
.warning {
margin-top: 20px;
padding: 16px;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
color: #856404;
font-size: 14px;
}
@media (max-width: 480px) {
.container { padding: 20px; }
h1 { font-size: 20px; }
.filename { font-size: 16px; }
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📱</div>
<h1>APK文件下载</h1>
<div class="filename">${filename}</div>
<div class="filesize">文件大小: ${fileSize}</div>
<a href="/download" class="download-btn">立即下载</a>
<div class="warning">
⚠️ 此下载链接有效期为10分钟请及时下载
</div>
</div>
</body>
</html>
`
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<ChildProcess> {
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<string> {
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