111
This commit is contained in:
506
src/services/CloudflareShareService.ts
Normal file
506
src/services/CloudflareShareService.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
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
|
||||
Reference in New Issue
Block a user