gongxiangp2/lib/task-detail-change-staging.ts

132 lines
5.0 KiB
TypeScript

import { constants as fsConstants } from 'fs'
import { copyFile, lstat, mkdir, rm } from 'fs/promises'
import path from 'path'
import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock'
import { normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security'
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
import { getUploadRootDir } from '@/lib/upload-paths'
import {
getOwnedUploadSession,
getUploadedFile,
isSafeUploadId,
} from '@/lib/upload-sessions'
const REQUEST_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const STAGING_FOLDER = 'task-detail-change-requests'
export type TaskDetailStagedFile = {
relativePath: string
fileName: string
fileSize: number
}
function getStagingRoot() {
return path.join(getUploadRootDir(), STAGING_FOLDER)
}
function getRequestDirectory(requestId: string) {
if (!REQUEST_ID_PATTERN.test(String(requestId || '').trim())) {
throw new Error('图片变更申请标识无效')
}
const directory = resolveStrictChildPath(getStagingRoot(), requestId)
if (!directory) throw new Error('图片变更暂存目录无效')
return directory
}
export async function withTaskDetailStagedUploads<T>(input: {
requestId: string
ownerUserId: string
uploadIds: string[]
commit: (files: Map<string, TaskDetailStagedFile>) => Promise<T>
}): Promise<T> {
const uploadIds = Array.from(new Set(
input.uploadIds.map((value) => String(value || '').trim()).filter(isSafeUploadId),
))
if (uploadIds.length !== input.uploadIds.length) {
throw new Error('存在无效或重复的上传会话,请重新选择图片')
}
const requestDirectory = getRequestDirectory(input.requestId)
await mkdir(getStagingRoot(), { recursive: true })
const release = uploadIds.length > 0
? await acquireTaskMaterialUploadSessionLocks(uploadIds)
: null
let committed = false
try {
release?.assertHeld()
await mkdir(requestDirectory, { recursive: false })
const staged = new Map<string, TaskDetailStagedFile>()
for (let index = 0; index < uploadIds.length; index += 1) {
release?.assertHeld()
const uploadId = uploadIds[index]
const session = await getOwnedUploadSession(uploadId, input.ownerUserId)
if (!session) throw new Error('图片上传会话已失效,请重新选择图片')
const sourcePath = await getUploadedFile(uploadId)
if (!sourcePath) throw new Error('已上传图片文件缺失,请重新选择图片')
const sourceStats = await lstat(sourcePath)
if (!sourceStats.isFile() || sourceStats.isSymbolicLink() || sourceStats.size <= 0) {
throw new Error('已上传图片文件状态异常,请重新选择图片')
}
const safeName = normalizeSafeFileName(session.fileName)
if (!safeName) throw new Error('图片文件名无效')
const storedName = `${String(index).padStart(3, '0')}-${safeName}`
const targetPath = resolveStrictChildPath(requestDirectory, storedName)
if (!targetPath) throw new Error('图片暂存路径无效')
await copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL)
const targetStats = await lstat(targetPath)
if (!targetStats.isFile() || targetStats.isSymbolicLink() || targetStats.size !== sourceStats.size) {
throw new Error('图片持久暂存校验失败')
}
staged.set(uploadId, {
relativePath: path.posix.join(STAGING_FOLDER, input.requestId, storedName),
fileName: safeName,
fileSize: targetStats.size,
})
}
release?.assertHeld()
const result = await input.commit(staged)
committed = true
await Promise.allSettled(uploadIds.map((uploadId) => cleanupConsumedUploadSession(uploadId)))
return result
} finally {
try {
if (release) await release()
} finally {
if (!committed) {
await rm(requestDirectory, { recursive: true, force: true }).catch(() => undefined)
}
}
}
}
export function resolveTaskDetailStagedFile(
requestId: string,
file: TaskDetailStagedFile,
): { trusted: true; filePath: string; fileName: string; fileSize: number } {
const requestDirectory = getRequestDirectory(requestId)
const expectedPrefix = `${STAGING_FOLDER}/${requestId}/`
const normalized = String(file?.relativePath || '').replace(/\\/g, '/')
if (!normalized.startsWith(expectedPrefix)) {
throw new Error('图片审批暂存引用超出申请范围')
}
const baseName = normalized.slice(expectedPrefix.length)
const filePath = resolveStrictChildPath(requestDirectory, baseName)
if (!filePath) throw new Error('图片审批暂存引用无效')
const fileName = normalizeSafeFileName(file.fileName)
if (!fileName) throw new Error('图片审批暂存文件名无效')
return {
trusted: true,
filePath,
fileName,
fileSize: Number(file.fileSize),
}
}
export async function cleanupTaskDetailChangeStaging(requestId: string) {
await rm(getRequestDirectory(requestId), { recursive: true, force: true })
}