506 lines
14 KiB
TypeScript
506 lines
14 KiB
TypeScript
|
|
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
|