fenghuo/packages/tus/src/handlers/GetHandler.ts

190 lines
5.8 KiB
TypeScript
Raw Normal View History

2025-05-27 16:56:50 +08:00
/**
* GetHandler.ts
* HTTP GET请求
* 使Web服务
*/
import stream from 'node:stream'
import { BaseHandler } from './BaseHandler'
import type http from 'node:http'
import type { RouteHandler } from '../types'
import { ERRORS, Upload } from '../utils'
/**
* GetHandler类
* GET请求
*
* 使
* const handler = new GetHandler()
* handler.registerPath('/custom', customHandler)
*/
export class GetHandler extends BaseHandler {
// 使用Map存储路径与处理函数的映射关系提供O(1)的查找时间复杂度
paths: Map<string,RouteHandler> = new Map()
/**
* MIME类型是否符合RFC1341规范
* MIME类型text/plain; charset=utf-8
* O(n)n为字符串长度
*
*/
reMimeType =
// biome-ignore lint/suspicious/noControlCharactersInRegex: it's fine
/^(?:application|audio|example|font|haptics|image|message|model|multipart|text|video|x-(?:[0-9A-Za-z!#$%&'*+.^_`|~-]+))\/([0-9A-Za-z!#$%&'*+.^_`|~-]+)((?:[ ]*;[ ]*[0-9A-Za-z!#$%&'*+.^_`|~-]+=(?:[0-9A-Za-z!#$%&'*+.^_`|~-]+|"(?:[^"\\]|\.)*"))*)$/
/**
* MIME类型白名单
* 使Set数据结构O(1)
*
*/
mimeInlineBrowserWhitelist = new Set([
'text/plain',
'image/png',
'image/jpeg',
'image/gif',
'image/bmp',
'image/webp',
'audio/wave',
'audio/wav',
'audio/x-wav',
'audio/x-pn-wav',
'audio/webm',
'audio/ogg',
'video/mp4',
'video/webm',
'video/ogg',
'application/ogg',
])
/**
*
*
*
* - path: 请求路径
* - handler: 处理函数
* O(1)
*
*/
registerPath(path: string, handler: RouteHandler): void {
this.paths.set(path, handler)
}
/**
*
* GET请求
*
* - req: HTTP请求对象
* - res: HTTP响应对象
* void
* FILE_NOT_FOUND错误
* O(n)n为文件大小
*
*/
async send(
req: http.IncomingMessage,
res: http.ServerResponse
// biome-ignore lint/suspicious/noConfusingVoidType: it's fine
): Promise<stream.Writable | void> {
// 检查是否注册了自定义路径处理
if (this.paths.has(req.url as string)) {
const handler = this.paths.get(req.url as string) as RouteHandler
return handler(req, res)
}
// 检查数据存储是否支持读取操作
if (!('read' in this.store)) {
throw ERRORS.FILE_NOT_FOUND
}
// 从请求中提取文件ID
const id = this.getFileIdFromRequest(req)
if (!id) {
throw ERRORS.FILE_NOT_FOUND
}
// 执行自定义请求处理回调
if (this.options.onIncomingRequest) {
await this.options.onIncomingRequest(req, res, id)
}
// 获取文件上传状态
const stats = await this.store.getUpload(id)
// 验证文件是否完整
if (!stats || stats.offset !== stats.size) {
throw ERRORS.FILE_NOT_FOUND
}
// 处理内容类型和内容处置头
const { contentType, contentDisposition } = this.filterContentType(stats)
// 创建文件读取流
// @ts-expect-error exists if supported
const file_stream = await this.store.read(id)
const headers = {
'Content-Length': stats.offset,
'Content-Type': contentType,
'Content-Disposition': contentDisposition,
}
res.writeHead(200, headers)
// 使用流管道传输数据
return stream.pipeline(file_stream, res, () => {
// 忽略流传输错误
})
}
/**
*
* Content-Type和Content-Disposition头
*
* - stats: 文件上传状态对象
* contentType和contentDisposition的对象
* O(1)
* MIME类型验证规则
*/
filterContentType(stats: Upload): {
contentType: string
contentDisposition: string
} {
let contentType: string
let contentDisposition: string
// 从元数据中提取文件类型和名称
const { filetype, filename } = stats.metadata ?? {}
// 验证文件类型格式
if (filetype && this.reMimeType.test(filetype)) {
contentType = filetype
// 检查是否在白名单中
if (this.mimeInlineBrowserWhitelist.has(filetype)) {
contentDisposition = 'inline'
} else {
contentDisposition = 'attachment'
}
} else {
// 使用默认类型并强制下载
contentType = 'application/octet-stream'
contentDisposition = 'attachment'
}
// 添加文件名到内容处置头
if (filename) {
contentDisposition += `; filename=${this.quote(filename)}`
}
return {
contentType,
contentDisposition,
}
}
/**
*
*
*
* - value: 需要转义的字符串
*
* O(n)n为字符串长度
* 使
*/
quote(value: string) {
return `"${value.replace(/"/g, '\\"')}"`
}
}