209 lines
10 KiB
TypeScript
209 lines
10 KiB
TypeScript
import { promises as fs } from 'fs'
|
|
import path from 'path'
|
|
import type { Prisma } from '@prisma/client'
|
|
import { deleteFromS3, getTrustedS3MutationKey } from '@/lib/s3'
|
|
import { getUploadRootDir } from '@/lib/upload-paths'
|
|
import { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPathFromAssetPath } from '@/lib/public-display'
|
|
import { findIndexedPurgeReferenceSources } from '@/lib/material-purge-reference-query'
|
|
|
|
export type PurgeFile = {
|
|
source: string
|
|
storage: 'local' | 's3'
|
|
key: string
|
|
bucket?: string
|
|
endpoint?: string
|
|
root?: string
|
|
fingerprint?: { size: number; mtimeNs: string } | null
|
|
outcome: 'pending' | 'deleted' | 'shared'
|
|
}
|
|
|
|
const MAX_FILES = 2000
|
|
|
|
/** 地址只决定当前项目的写入范围,历史域名不扩展删除权限。 */
|
|
export function identifyPurgeFile(source: string): Omit<PurgeFile, 'outcome'> | null {
|
|
const value = String(source || '').trim()
|
|
if (!value || value.startsWith('data:image/') || /^\/(?:brand|images|placeholder)[/.]/.test(value)) return null
|
|
if (/^https?:\/\//i.test(value)) {
|
|
const key = getTrustedS3MutationKey(value)
|
|
if (!process.env.S3_BUCKET) throw new Error('对象存储未配置,暂不能清理文件')
|
|
return { source: value, storage: 's3', key, bucket: process.env.S3_BUCKET,
|
|
endpoint: String(process.env.S3_ENDPOINT || '').split('#')[0].trim() }
|
|
}
|
|
const normalized = value.replace(/\\/g, '/')
|
|
if (/[\x00-\x1f\x7f]/.test(normalized) || normalized.split('/').includes('..')) {
|
|
throw new Error('文件地址不安全,已拒绝删除')
|
|
}
|
|
const root = path.resolve(getUploadRootDir())
|
|
const relative = normalized.replace(/^\.?\/?uploads\//, '')
|
|
const absolute = normalized !== relative
|
|
? path.resolve(root, relative)
|
|
: path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(process.cwd(), normalized)
|
|
const key = path.relative(root, absolute).split(path.sep).join('/')
|
|
if (!key || key === '..' || key.startsWith('../') || path.isAbsolute(key)) {
|
|
throw new Error('文件不在本项目上传目录,已拒绝删除')
|
|
}
|
|
return { source: value, storage: 'local', key, root }
|
|
}
|
|
|
|
export function purgeFileIdentity(file: Pick<PurgeFile, 'storage' | 'key' | 'bucket'>) {
|
|
const key = file.storage === 'local' && process.platform === 'win32' ? file.key.toLowerCase() : file.key
|
|
return `${file.storage}:${file.bucket || ''}:${key}`
|
|
}
|
|
|
|
async function inspectLocalFile(key: string, directory = false) {
|
|
const root = path.resolve(getUploadRootDir())
|
|
let current = root
|
|
// 每一层都拒绝符号链接;不递归删除目录,也不沿链接枚举文件。
|
|
const parts = ['', ...key.split('/')]
|
|
for (let index = 0; index < parts.length; index += 1) {
|
|
if (parts[index]) current = path.join(current, parts[index])
|
|
let stat
|
|
try { stat = await fs.lstat(current, { bigint: true }) } catch (error: any) {
|
|
if (error.code === 'ENOENT') return null
|
|
throw error
|
|
}
|
|
if (stat.isSymbolicLink()) throw new Error('文件路径包含符号链接,已拒绝删除')
|
|
if (index === parts.length - 1) {
|
|
if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error('文件清理目标类型不符,已拒绝删除')
|
|
// 纳秒时间戳用字符串保存,避免 JSON/数据库浮点往返导致误判。
|
|
return { size: Number(stat.size), mtimeNs: stat.mtimeNs.toString() }
|
|
}
|
|
if (!stat.isDirectory()) throw new Error('文件目录无效,已拒绝删除')
|
|
}
|
|
return null
|
|
}
|
|
|
|
export async function collectPurgeFiles(material: {
|
|
id: string; previewUrl: string; zipFilePath: string; displayImages: string[]
|
|
videoSourceUrl: string | null; videoPreview540Url: string | null
|
|
videoPreviewClipUrl: string | null; videoCoverUrl: string | null
|
|
assets: { fileUrl: string }[]; skus: { filePath: string; previewUrl: string | null }[]
|
|
}): Promise<PurgeFile[]> {
|
|
const sources = [material.previewUrl, material.zipFilePath, ...material.displayImages,
|
|
material.videoSourceUrl, material.videoPreview540Url, material.videoPreviewClipUrl, material.videoCoverUrl,
|
|
...material.assets.map(asset => asset.fileUrl),
|
|
...material.skus.flatMap(sku => [sku.filePath, sku.previewUrl])]
|
|
for (const asset of material.assets) {
|
|
const derived = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl)
|
|
if (derived) sources.push(buildPublicDisplayUrl(derived))
|
|
}
|
|
// 这些目录由素材 UUID 独占;列出文件写入清单,不做目录递归删除。
|
|
const root = path.resolve(getUploadRootDir())
|
|
async function walk(relative: string) {
|
|
const absolute = path.join(root, relative)
|
|
let entries
|
|
try {
|
|
if (!await inspectLocalFile(relative, true)) return
|
|
entries = await fs.readdir(absolute, { withFileTypes: true })
|
|
} catch (error: any) { if (error.code === 'ENOENT') return; throw error }
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink()) throw new Error('素材目录包含符号链接,已拒绝删除')
|
|
const child = `${relative}/${entry.name}`
|
|
if (entry.isDirectory()) await walk(child)
|
|
else if (entry.isFile()) sources.push(`uploads/${child}`)
|
|
else throw new Error('素材目录包含特殊文件,已拒绝删除')
|
|
if (sources.length > MAX_FILES) throw new Error('素材文件过多,请联系管理员分批清理')
|
|
}
|
|
}
|
|
// 先校验上传根目录本身,避免只检查最末级目录时沿根目录链接走出边界。
|
|
try {
|
|
const stat = await fs.lstat(root)
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('上传目录无效,已拒绝删除')
|
|
} catch (error: any) { if (error.code !== 'ENOENT') throw error }
|
|
await walk(`product-package/${material.id}`)
|
|
await walk(`skus/${material.id}`)
|
|
const files = new Map<string, PurgeFile>()
|
|
for (const source of sources) {
|
|
if (!source) continue
|
|
const file = identifyPurgeFile(source)
|
|
if (!file) continue
|
|
const identity = purgeFileIdentity(file)
|
|
if (!files.has(identity)) files.set(identity, {
|
|
...file, outcome: 'pending',
|
|
...(file.storage === 'local' ? { fingerprint: await inspectLocalFile(file.key) } : {}),
|
|
})
|
|
if (files.size > MAX_FILES) throw new Error('素材文件过多,请联系管理员分批清理')
|
|
}
|
|
return Array.from(files.values())
|
|
}
|
|
|
|
function stringsIn(value: unknown): string[] {
|
|
if (typeof value === 'string') return [value]
|
|
if (Array.isArray(value)) return value.flatMap(stringsIn)
|
|
if (value && typeof value === 'object') return Object.values(value).flatMap(stringsIn)
|
|
return []
|
|
}
|
|
|
|
/** 生产清理按目标文件查索引;无 files 参数的全量读取仅供只读审计核对。 */
|
|
export async function findPurgeFileReferences(db: Prisma.TransactionClient, files?: readonly PurgeFile[]): Promise<Set<string>> {
|
|
if (files && !files.length) return new Set()
|
|
let sources: string[]
|
|
let assets: { fileUrl: string }[]
|
|
if (files) {
|
|
const rows = await findIndexedPurgeReferenceSources(db, files)
|
|
sources = rows.flatMap(row => row.sources).filter((source): source is string => typeof source === 'string')
|
|
assets = rows.filter(row => row.derived).flatMap(row => row.sources.map(fileUrl => ({ fileUrl })))
|
|
} else {
|
|
const rows = await Promise.all([
|
|
db.material.findMany({ select: { previewUrl: true, zipFilePath: true, displayImages: true,
|
|
videoSourceUrl: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true } }),
|
|
db.materialAsset.findMany({ select: { fileUrl: true } }),
|
|
db.materialSku.findMany({ select: { filePath: true, previewUrl: true } }),
|
|
db.brandKnowledge.findMany({ select: { filePath: true, previewImagePath: true, previewImageSlices: true } }),
|
|
db.systemSettings.findMany({ select: { headerLogoUrl: true, loginLogoUrl: true } }),
|
|
db.performanceSurveyWorkItem.findMany({ select: { imageUrls: true } }),
|
|
db.workReviewVersion.findMany({ select: { imageUrls: true } }),
|
|
db.productPackageUploadJob.findMany({ where: { status: { in: ['queued', 'processing'] } }, select: { assets: true } }),
|
|
db.taskDetailChangeRequest.findMany({ where: { status: { notIn: ['applied', 'rejected', 'expired', 'cancelled'] } }, select: { operations: true } }),
|
|
])
|
|
sources = stringsIn(rows)
|
|
assets = rows[1] as { fileUrl: string }[]
|
|
}
|
|
const targets = files && new Set(files.map(purgeFileIdentity))
|
|
const identities = new Set<string>()
|
|
const protect = (identity: string) => { if (!targets || targets.has(identity)) identities.add(identity) }
|
|
for (const source of sources) {
|
|
try {
|
|
const file = identifyPurgeFile(source)
|
|
if (file) protect(purgeFileIdentity(file))
|
|
} catch {
|
|
// 外部只读引用不扩大写权限;历史域名的同路径仍保护当前桶中的对象。
|
|
if (/^https?:\/\//i.test(source)) {
|
|
try {
|
|
let key = decodeURIComponent(new URL(source).pathname.slice(1))
|
|
const bucket = process.env.S3_BUCKET || ''
|
|
if (bucket && key.startsWith(`${bucket}/`)) key = key.slice(bucket.length + 1)
|
|
protect(purgeFileIdentity({ storage: 's3', bucket, key }))
|
|
} catch { /* 非文件字符串不构成引用 */ }
|
|
}
|
|
}
|
|
}
|
|
for (const asset of assets) {
|
|
const derived = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl)
|
|
const source = derived && buildPublicDisplayUrl(derived)
|
|
if (source) {
|
|
const file = identifyPurgeFile(source)
|
|
if (file) protect(purgeFileIdentity(file))
|
|
}
|
|
}
|
|
return identities
|
|
}
|
|
|
|
export async function removePurgeFile(file: PurgeFile): Promise<void> {
|
|
const current = identifyPurgeFile(file.source)
|
|
if (!current || purgeFileIdentity(current) !== purgeFileIdentity(file) || current.root !== file.root || current.endpoint !== file.endpoint) {
|
|
throw new Error('存储配置已变化,已停止文件清理,请联系管理员')
|
|
}
|
|
if (file.storage === 's3') {
|
|
try { await deleteFromS3(file.source) }
|
|
catch { throw new Error('存储暂时无法访问,文件清理未完成,请重试') }
|
|
return
|
|
}
|
|
const stat = await inspectLocalFile(file.key)
|
|
if (!stat) return // 删除成功但写回之前进程中断时,重试仍可完成。
|
|
if (!file.fingerprint || stat.size !== file.fingerprint.size || stat.mtimeNs !== file.fingerprint.mtimeNs) {
|
|
throw new Error('文件已发生变化,已停止清理,请联系管理员')
|
|
}
|
|
await fs.unlink(path.join(getUploadRootDir(), file.key))
|
|
}
|