107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
Res,
|
|
Req,
|
|
NotFoundException,
|
|
Logger,
|
|
} from '@nestjs/common'
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'
|
|
import { Request, Response } from 'express'
|
|
import { ConfigService } from '@nestjs/config'
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
|
|
@ApiTags('Downloads')
|
|
@Controller('downloads')
|
|
export class DownloadController {
|
|
private readonly logger = new Logger(DownloadController.name)
|
|
private readonly uploadDir: string
|
|
|
|
constructor(private readonly configService: ConfigService) {
|
|
this.uploadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads'
|
|
}
|
|
|
|
@Get(':filename')
|
|
@ApiOperation({ summary: '下载文件 (支持断点续传)' })
|
|
@ApiParam({ name: 'filename', description: '文件名' })
|
|
@ApiResponse({ status: 200, description: '文件内容' })
|
|
@ApiResponse({ status: 206, description: '部分内容 (断点续传)' })
|
|
@ApiResponse({ status: 404, description: '文件不存在' })
|
|
@ApiResponse({ status: 416, description: 'Range 不满足' })
|
|
async downloadFile(
|
|
@Param('filename') filename: string,
|
|
@Req() req: Request,
|
|
@Res() res: Response,
|
|
): Promise<void> {
|
|
// 安全检查:防止路径遍历攻击
|
|
const sanitizedFilename = path.basename(filename)
|
|
const filePath = path.join(this.uploadDir, sanitizedFilename)
|
|
|
|
// 检查文件是否存在
|
|
if (!fs.existsSync(filePath)) {
|
|
throw new NotFoundException('文件不存在')
|
|
}
|
|
|
|
const stat = fs.statSync(filePath)
|
|
const fileSize = stat.size
|
|
const range = req.headers.range
|
|
|
|
// 设置通用响应头
|
|
const ext = path.extname(filename).toLowerCase()
|
|
const mimeTypes: Record<string, string> = {
|
|
'.apk': 'application/vnd.android.package-archive',
|
|
'.ipa': 'application/octet-stream',
|
|
}
|
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
|
|
|
// 设置缓存和下载头
|
|
res.setHeader('Accept-Ranges', 'bytes')
|
|
res.setHeader('Content-Type', contentType)
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename="${encodeURIComponent(sanitizedFilename)}"`,
|
|
)
|
|
res.setHeader('Cache-Control', 'public, max-age=86400') // 缓存1天
|
|
res.setHeader('ETag', `"${stat.mtime.getTime()}-${fileSize}"`)
|
|
res.setHeader('Last-Modified', stat.mtime.toUTCString())
|
|
|
|
if (range) {
|
|
// 断点续传请求
|
|
const parts = range.replace(/bytes=/, '').split('-')
|
|
const start = parseInt(parts[0], 10)
|
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
|
|
|
// 验证 Range 有效性
|
|
if (start >= fileSize || end >= fileSize || start > end) {
|
|
res.status(416)
|
|
res.setHeader('Content-Range', `bytes */${fileSize}`)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const chunkSize = end - start + 1
|
|
|
|
this.logger.log(
|
|
`Range request: ${filename}, bytes ${start}-${end}/${fileSize}`,
|
|
)
|
|
|
|
res.status(206)
|
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`)
|
|
res.setHeader('Content-Length', chunkSize)
|
|
|
|
const stream = fs.createReadStream(filePath, { start, end })
|
|
stream.pipe(res)
|
|
} else {
|
|
// 完整文件请求
|
|
this.logger.log(`Full download: ${filename}, size: ${fileSize}`)
|
|
|
|
res.setHeader('Content-Length', fileSize)
|
|
|
|
const stream = fs.createReadStream(filePath)
|
|
stream.pipe(res)
|
|
}
|
|
}
|
|
}
|