Files
server/src/services/CloudflareShareService.ts

506 lines
14 KiB
TypeScript
Raw Normal View History

2026-02-09 16:34:01 +08:00
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