gongxiangp2/lib/material-upload-consumption-journal.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

319 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createHash, randomBytes } from 'crypto'
import { lstat, open, readFile, rename, unlink } from 'fs/promises'
import path from 'path'
import {
ensureSafeDirectoryWithinRoot,
isPathFreeOfSymbolicLinks,
normalizeSafeFileName,
resolveStrictChildPath,
} from './path-security'
import { getUploadRootDir } from './upload-paths'
const JOURNAL_DIRECTORY_NAME = '.material-upload-consumption'
const JOURNAL_VERSION = 1
const MAX_JOURNAL_BYTES = 16 * 1024
const SAFE_UPLOAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
export interface MaterialUploadConsumptionRecord {
version: 1
uploadId: string
ownerId: string
mergedPath: string
finalPath: string
storedFinalPath: string
fileName: string
size: number
createdAt: number
}
export interface RecoveredMaterialUploadConsumption {
record: MaterialUploadConsumptionRecord
sourcePath: string
sourceLocation: 'merged' | 'final'
}
export class MaterialUploadConsumptionJournalError extends Error {
constructor(message = '上传源文件恢复记录异常,请稍后重试') {
super(message)
this.name = 'MaterialUploadConsumptionJournalError'
}
}
function isSafeOwnerId(ownerId: string): boolean {
return (
ownerId.length > 0
&& Buffer.byteLength(ownerId, 'utf8') <= 512
&& !/[\x00-\x1f\x7f]/.test(ownerId)
)
}
function isPathInside(basePath: string, targetPath: string): boolean {
const relativePath = path.relative(path.resolve(basePath), path.resolve(targetPath))
return Boolean(
relativePath
&& relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath),
)
}
function resolveJournalPath(ownerId: string, uploadId: string): string {
const uploadRoot = getUploadRootDir()
const digest = createHash('sha256')
.update(ownerId)
.update('\0')
.update(uploadId)
.digest('hex')
const journalPath = resolveStrictChildPath(
path.join(uploadRoot, JOURNAL_DIRECTORY_NAME),
`${digest}.json`,
)
if (!journalPath) {
throw new MaterialUploadConsumptionJournalError()
}
return journalPath
}
function normalizeRecord(input: unknown): MaterialUploadConsumptionRecord {
const value = input as Partial<MaterialUploadConsumptionRecord> | null
const uploadId = String(value?.uploadId || '').trim()
const ownerId = String(value?.ownerId || '').trim()
const mergedPath = path.resolve(String(value?.mergedPath || ''))
const finalPath = path.resolve(String(value?.finalPath || ''))
const storedFinalPath = String(value?.storedFinalPath || '').trim()
const fileName = normalizeSafeFileName(String(value?.fileName || ''))
const size = Number(value?.size)
const createdAt = Number(value?.createdAt)
const uploadRoot = path.resolve(getUploadRootDir())
const tempRoot = path.join(uploadRoot, 'temp')
const expectedSessionDir = path.join(tempRoot, uploadId)
if (
value?.version !== JOURNAL_VERSION
|| !SAFE_UPLOAD_ID_PATTERN.test(uploadId)
|| !isSafeOwnerId(ownerId)
|| !fileName
|| !Number.isSafeInteger(size)
|| size <= 0
|| !Number.isFinite(createdAt)
|| createdAt <= 0
|| path.dirname(mergedPath) !== expectedSessionDir
|| !path.basename(mergedPath).startsWith('merged-')
|| !isPathInside(tempRoot, mergedPath)
|| path.dirname(finalPath) !== uploadRoot
|| !normalizeSafeFileName(path.basename(finalPath))
|| path.basename(finalPath) !== fileName
|| path.resolve(storedFinalPath) !== finalPath
) {
throw new MaterialUploadConsumptionJournalError()
}
return {
version: JOURNAL_VERSION,
uploadId,
ownerId,
mergedPath,
finalPath,
storedFinalPath,
fileName,
size,
createdAt,
}
}
async function readJournalFile(
ownerId: string,
uploadId: string,
): Promise<MaterialUploadConsumptionRecord | null> {
if (!SAFE_UPLOAD_ID_PATTERN.test(uploadId) || !isSafeOwnerId(ownerId)) {
throw new MaterialUploadConsumptionJournalError()
}
const journalPath = resolveJournalPath(ownerId, uploadId)
let stats
try {
stats = await lstat(journalPath)
} catch (error: any) {
if (error?.code === 'ENOENT') {
return null
}
throw new MaterialUploadConsumptionJournalError()
}
if (!stats.isFile() || stats.isSymbolicLink() || stats.size <= 0 || stats.size > MAX_JOURNAL_BYTES) {
throw new MaterialUploadConsumptionJournalError()
}
let record: MaterialUploadConsumptionRecord
try {
record = normalizeRecord(JSON.parse(await readFile(journalPath, 'utf8')))
} catch (error) {
if (error instanceof MaterialUploadConsumptionJournalError) {
throw error
}
throw new MaterialUploadConsumptionJournalError()
}
if (record.ownerId !== ownerId || record.uploadId !== uploadId) {
throw new MaterialUploadConsumptionJournalError()
}
return record
}
async function inspectRegularFile(
uploadRoot: string,
filePath: string,
expectedSize: number,
): Promise<'missing' | 'valid' | 'invalid'> {
let stats
try {
stats = await lstat(filePath)
} catch (error: any) {
if (error?.code === 'ENOENT') {
return 'missing'
}
throw new MaterialUploadConsumptionJournalError()
}
if (
stats.isSymbolicLink()
|| !stats.isFile()
|| stats.size !== expectedSize
|| !(await isPathFreeOfSymbolicLinks(uploadRoot, filePath))
) {
return 'invalid'
}
return 'valid'
}
async function syncDirectory(directoryPath: string): Promise<void> {
const directory = await open(directoryPath, 'r')
try {
await directory.sync()
} catch (error: any) {
// Windows 不支持目录 fsync;Linux 生产环境的其他错误必须上抛。
if (process.platform !== 'win32' || !['EINVAL', 'EPERM', 'ENOTSUP'].includes(error?.code)) {
throw error
}
} finally {
await directory.close()
}
}
export async function writeMaterialUploadConsumptionJournal(input: {
uploadId: string
ownerId: string
mergedPath: string
finalPath: string
storedFinalPath: string
fileName: string
size: number
}): Promise<MaterialUploadConsumptionRecord> {
const record = normalizeRecord({
version: JOURNAL_VERSION,
...input,
createdAt: Date.now(),
})
const uploadRoot = path.resolve(getUploadRootDir())
if (await inspectRegularFile(uploadRoot, record.mergedPath, record.size) !== 'valid') {
throw new MaterialUploadConsumptionJournalError('上传源文件状态异常,未执行移动')
}
if (await inspectRegularFile(uploadRoot, record.finalPath, record.size) !== 'missing') {
throw new MaterialUploadConsumptionJournalError('上传目标文件状态异常,未执行移动')
}
const existing = await readJournalFile(record.ownerId, record.uploadId)
if (existing) {
const comparableExisting = { ...existing, createdAt: 0 }
const comparableRecord = { ...record, createdAt: 0 }
if (JSON.stringify(comparableExisting) !== JSON.stringify(comparableRecord)) {
throw new MaterialUploadConsumptionJournalError()
}
return existing
}
const journalDirectory = await ensureSafeDirectoryWithinRoot(
uploadRoot,
JOURNAL_DIRECTORY_NAME,
)
if (!journalDirectory) {
throw new MaterialUploadConsumptionJournalError()
}
const journalPath = resolveJournalPath(record.ownerId, record.uploadId)
const temporaryPath = resolveStrictChildPath(
journalDirectory,
`${path.basename(journalPath)}.${randomBytes(8).toString('hex')}.tmp`,
)
if (!temporaryPath) {
throw new MaterialUploadConsumptionJournalError()
}
const file = await open(temporaryPath, 'wx', 0o600)
try {
await file.writeFile(`${JSON.stringify(record)}\n`, 'utf8')
await file.sync()
} catch (error) {
await file.close().catch(() => undefined)
await unlink(temporaryPath).catch(() => undefined)
throw error
}
await file.close()
try {
await rename(temporaryPath, journalPath)
await syncDirectory(journalDirectory)
} catch (error) {
await unlink(temporaryPath).catch(() => undefined)
throw error
}
return record
}
export async function recoverMaterialUploadConsumptionJournal(input: {
ownerId: string
uploadId: string
}): Promise<RecoveredMaterialUploadConsumption | null> {
const record = await readJournalFile(input.ownerId, input.uploadId)
if (!record) {
return null
}
const uploadRoot = path.resolve(getUploadRootDir())
const [mergedState, finalState] = await Promise.all([
inspectRegularFile(uploadRoot, record.mergedPath, record.size),
inspectRegularFile(uploadRoot, record.finalPath, record.size),
])
if (mergedState === 'valid' && finalState === 'missing') {
return { record, sourcePath: record.mergedPath, sourceLocation: 'merged' }
}
if (mergedState === 'missing' && finalState === 'valid') {
return { record, sourcePath: record.finalPath, sourceLocation: 'final' }
}
throw new MaterialUploadConsumptionJournalError()
}
export async function syncMaterialUploadRootDirectory(): Promise<void> {
await syncDirectory(getUploadRootDir())
}
export async function removeMaterialUploadConsumptionJournal(input: {
ownerId: string
uploadId: string
expectedFinalPath?: string
}): Promise<boolean> {
const record = await readJournalFile(input.ownerId, input.uploadId)
if (!record) {
return false
}
if (
input.expectedFinalPath
&& path.resolve(input.expectedFinalPath) !== record.finalPath
) {
throw new MaterialUploadConsumptionJournalError()
}
const journalPath = resolveJournalPath(record.ownerId, record.uploadId)
await unlink(journalPath)
await syncDirectory(path.dirname(journalPath))
return true
}