132 lines
5.0 KiB
TypeScript
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 })
|
|
}
|