gongxiangp2/lib/material-purge-files.ts

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))
}