257 lines
8.7 KiB
TypeScript
Executable File
257 lines
8.7 KiB
TypeScript
Executable File
import debug from 'debug'
|
||
|
||
import { BaseHandler } from './BaseHandler'
|
||
|
||
import { validateHeader } from '../validators/HeaderValidator'
|
||
|
||
import type http from 'node:http'
|
||
import type { ServerOptions, WithRequired } from '../types'
|
||
import { DataStore, Uid, CancellationContext, ERRORS, Metadata, Upload, EVENTS } from '../utils'
|
||
|
||
const log = debug('tus-node-server:handlers:post')
|
||
|
||
/**
|
||
* PostHandler 类用于处理 HTTP POST 请求,主要用于在 DataStore 中创建文件。
|
||
* 该类继承自 BaseHandler,并重写了部分方法以实现特定的功能。
|
||
*/
|
||
export class PostHandler extends BaseHandler {
|
||
// 重写 BaseHandler 中的 options 类型,确保在构造函数中设置了 namingFunction
|
||
declare options: WithRequired<ServerOptions, 'namingFunction'>
|
||
|
||
/**
|
||
* 构造函数,初始化 PostHandler 实例。
|
||
* @param store - DataStore 实例,用于存储上传的文件。
|
||
* @param options - 服务器配置选项,包含 namingFunction 等。
|
||
* @throws 如果 namingFunction 不是函数,则抛出错误。
|
||
*/
|
||
constructor(store: DataStore, options: ServerOptions) {
|
||
if (options.namingFunction && typeof options.namingFunction !== 'function') {
|
||
throw new Error("'namingFunction' must be a function")
|
||
}
|
||
|
||
if (!options.namingFunction) {
|
||
options.namingFunction = Uid.rand
|
||
}
|
||
|
||
super(store, options)
|
||
}
|
||
|
||
/**
|
||
* 在 DataStore 中创建文件。
|
||
* @param req - HTTP 请求对象。
|
||
* @param res - HTTP 响应对象。
|
||
* @param context - 取消操作的上下文。
|
||
* @returns 返回处理后的 HTTP 响应对象。
|
||
* @throws 如果请求头中包含 'upload-concat' 但 DataStore 不支持 'concatentation' 扩展,则抛出错误。
|
||
* @throws 如果请求头中 'upload-length' 和 'upload-defer-length' 同时存在或同时不存在,则抛出错误。
|
||
* @throws 如果 'upload-metadata' 解析失败,则抛出错误。
|
||
* @throws 如果文件大小超过配置的最大值,则抛出错误。
|
||
*/
|
||
async send(
|
||
req: http.IncomingMessage,
|
||
res: http.ServerResponse,
|
||
context: CancellationContext
|
||
) {
|
||
if ('upload-concat' in req.headers && !this.store.hasExtension('concatentation')) {
|
||
throw ERRORS.UNSUPPORTED_CONCATENATION_EXTENSION
|
||
}
|
||
|
||
const upload_length = req.headers['upload-length'] as string | undefined
|
||
const upload_defer_length = req.headers['upload-defer-length'] as string | undefined
|
||
const upload_metadata = req.headers['upload-metadata'] as string | undefined
|
||
|
||
if (
|
||
upload_defer_length !== undefined && // 如果扩展不支持,则抛出错误
|
||
!this.store.hasExtension('creation-defer-length')
|
||
) {
|
||
throw ERRORS.UNSUPPORTED_CREATION_DEFER_LENGTH_EXTENSION
|
||
}
|
||
|
||
if ((upload_length === undefined) === (upload_defer_length === undefined)) {
|
||
throw ERRORS.INVALID_LENGTH
|
||
}
|
||
|
||
let metadata: ReturnType<(typeof Metadata)['parse']> | undefined
|
||
if ('upload-metadata' in req.headers) {
|
||
try {
|
||
metadata = Metadata.parse(upload_metadata)
|
||
} catch {
|
||
throw ERRORS.INVALID_METADATA
|
||
}
|
||
}
|
||
|
||
let id: string
|
||
try {
|
||
id = await this.options.namingFunction(req, metadata)
|
||
} catch (error) {
|
||
log('create: check your `namingFunction`. Error', error)
|
||
throw error
|
||
}
|
||
|
||
const maxFileSize = await this.getConfiguredMaxSize(req, id)
|
||
|
||
if (
|
||
upload_length &&
|
||
maxFileSize > 0 &&
|
||
Number.parseInt(upload_length, 10) > maxFileSize
|
||
) {
|
||
throw ERRORS.ERR_MAX_SIZE_EXCEEDED
|
||
}
|
||
|
||
if (this.options.onIncomingRequest) {
|
||
await this.options.onIncomingRequest(req, res, id)
|
||
}
|
||
|
||
const upload = new Upload({
|
||
id,
|
||
size: upload_length ? Number.parseInt(upload_length, 10) : undefined,
|
||
offset: 0,
|
||
metadata,
|
||
})
|
||
|
||
if (this.options.onUploadCreate) {
|
||
try {
|
||
const resOrObject = await this.options.onUploadCreate(req, res, upload)
|
||
// 向后兼容,将在下一个主要版本中移除
|
||
// 由于在测试中模拟了实例,因此无法使用 `instanceof` 进行检查
|
||
if (
|
||
typeof (resOrObject as http.ServerResponse).write === 'function' &&
|
||
typeof (resOrObject as http.ServerResponse).writeHead === 'function'
|
||
) {
|
||
res = resOrObject as http.ServerResponse
|
||
} else {
|
||
// 由于 TS 只理解 instanceof,因此类型定义较为丑陋
|
||
type ExcludeServerResponse<T> = T extends http.ServerResponse ? never : T
|
||
const obj = resOrObject as ExcludeServerResponse<typeof resOrObject>
|
||
res = obj.res
|
||
if (obj.metadata) {
|
||
upload.metadata = obj.metadata
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
log(`onUploadCreate error: ${error.body}`)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
const lock = await this.acquireLock(req, id, context)
|
||
|
||
let isFinal: boolean
|
||
let url: string
|
||
|
||
// 推荐的响应默认值
|
||
const responseData = {
|
||
status: 201,
|
||
headers: {} as Record<string, string | number>,
|
||
body: '',
|
||
}
|
||
|
||
try {
|
||
await this.store.create(upload)
|
||
url = this.generateUrl(req, upload.id)
|
||
|
||
this.emit(EVENTS.POST_CREATE, req, res, upload, url)
|
||
|
||
isFinal = upload.size === 0 && !upload.sizeIsDeferred
|
||
|
||
// 如果请求中包含 Content-Type 头,并且使用了 creation-with-upload 扩展
|
||
if (validateHeader('content-type', req.headers['content-type'])) {
|
||
const bodyMaxSize = await this.calculateMaxBodySize(req, upload, maxFileSize)
|
||
const newOffset = await this.writeToStore(req, upload, bodyMaxSize, context)
|
||
|
||
responseData.headers['Upload-Offset'] = newOffset.toString()
|
||
isFinal = newOffset === Number.parseInt(upload_length as string, 10)
|
||
upload.offset = newOffset
|
||
}
|
||
} catch (e) {
|
||
context.abort()
|
||
throw e
|
||
} finally {
|
||
await lock.unlock()
|
||
}
|
||
// 上传完成后的处理逻辑
|
||
if (isFinal && this.options.onUploadFinish) {
|
||
try {
|
||
// 调用自定义的上传完成回调函数,传入请求、响应和上传对象
|
||
// 允许用户自定义上传完成后的处理逻辑
|
||
const resOrObject = await this.options.onUploadFinish(req, res, upload)
|
||
|
||
// 兼容性处理:检查返回值是否为 HTTP 响应对象
|
||
// 通过检查对象是否具有 write 和 writeHead 方法来判断
|
||
if (
|
||
typeof (resOrObject as http.ServerResponse).write === 'function' &&
|
||
typeof (resOrObject as http.ServerResponse).writeHead === 'function'
|
||
) {
|
||
// 如果直接返回 HTTP 响应对象,直接覆盖原响应对象
|
||
res = resOrObject as http.ServerResponse
|
||
} else {
|
||
// 处理自定义返回对象的情况
|
||
// 使用复杂的类型定义排除 ServerResponse 类型
|
||
type ExcludeServerResponse<T> = T extends http.ServerResponse ? never : T
|
||
|
||
// 将返回对象转换为非 ServerResponse 类型
|
||
const obj = resOrObject as ExcludeServerResponse<typeof resOrObject>
|
||
|
||
// 更新响应对象
|
||
res = obj.res
|
||
|
||
// 根据返回对象更新响应状态码
|
||
if (obj.status_code) responseData.status = obj.status_code
|
||
|
||
// 更新响应体
|
||
if (obj.body) responseData.body = obj.body
|
||
|
||
// 合并响应头,允许覆盖默认头
|
||
if (obj.headers)
|
||
responseData.headers = Object.assign(obj.headers, responseData.headers)
|
||
}
|
||
} catch (error: any) {
|
||
// 记录上传完成回调中的错误
|
||
log(`onUploadFinish: ${error.body}`)
|
||
|
||
// 抛出错误,中断上传流程
|
||
throw error
|
||
}
|
||
}
|
||
|
||
|
||
// Upload-Expires 响应头指示未完成的上传何时过期。
|
||
// 如果在创建时已知过期时间,则必须在响应中包含 Upload-Expires 头
|
||
if (
|
||
this.store.hasExtension('expiration') &&
|
||
this.store.getExpiration() > 0 &&
|
||
upload.creation_date
|
||
) {
|
||
const created = await this.store.getUpload(upload.id)
|
||
|
||
if (created.offset !== Number.parseInt(upload_length as string, 10)) {
|
||
const creation = new Date(upload.creation_date)
|
||
// 值必须为 RFC 7231 日期时间格式
|
||
responseData.headers['Upload-Expires'] = new Date(
|
||
creation.getTime() + this.store.getExpiration()
|
||
).toUTCString()
|
||
}
|
||
}
|
||
|
||
// 仅在最终的 HTTP 状态码为 201 或 3xx 时附加 Location 头
|
||
if (
|
||
responseData.status === 201 ||
|
||
(responseData.status >= 300 && responseData.status < 400)
|
||
) {
|
||
responseData.headers.Location = url
|
||
}
|
||
|
||
const writtenRes = this.write(
|
||
res,
|
||
responseData.status,
|
||
responseData.headers,
|
||
responseData.body
|
||
)
|
||
|
||
if (isFinal) {
|
||
this.emit(EVENTS.POST_FINISH, req, writtenRes, upload)
|
||
}
|
||
|
||
return writtenRes
|
||
}
|
||
} |