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

262 lines
8.7 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.

// Material upload 取消/过期清理公开接口。
// 所有入口只做认证和锁外候选筛选;真实删除统一交给独占锁安全 helper。
import { lstat, readdir } from 'fs/promises'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { resolveStrictChildPath } from '@/lib/path-security'
import {
safelyCleanupMaterialUploadSession,
type SafeMaterialUploadCleanupResult,
} from '@/lib/temp-cleanup'
import {
isSafeUploadId,
} from '@/lib/upload-sessions'
import { getUploadRootDir } from '@/lib/upload-paths'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(key: string, maxRequests: number): number | null {
const now = Date.now()
const current = requestCounts.get(key)
if (!current || current.resetAt <= now) {
requestCounts.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= maxRequests) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
function rateLimitResponse(retryAfter: number) {
return NextResponse.json(
{ error: '清理请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
/**
* GET - 系统管理员清理单个会话,或扫描过期/孤儿目录。
*/
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const sessionUser = session.user as any
if (!hasSystemAdminPermission(sessionUser?.role, Boolean(sessionUser?.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可执行批量清理' }, { status: 403 })
}
const retryAfter = consumeRateLimit(`bulk:${String(sessionUser?.id || 'unknown')}`, 5)
if (retryAfter) {
return rateLimitResponse(retryAfter)
}
const { searchParams } = new URL(req.url)
const uploadId = String(searchParams.get('uploadId') || '').trim()
const expiredOnly = searchParams.get('expiredOnly') !== 'false'
const maxAgeHours = Number(searchParams.get('maxAge') || '8')
const scanLimit = Number(searchParams.get('limit') || '100')
if (!Number.isInteger(maxAgeHours) || maxAgeHours < 1 || maxAgeHours > 168) {
return NextResponse.json({ error: 'maxAge 必须是 1 到 168 之间的整数' }, { status: 400 })
}
if (!Number.isInteger(scanLimit) || scanLimit < 1 || scanLimit > 500) {
return NextResponse.json({ error: 'limit 必须是 1 到 500 之间的整数' }, { status: 400 })
}
if (uploadId && !isSafeUploadId(uploadId)) {
return NextResponse.json({ error: 'uploadId 格式无效' }, { status: 400 })
}
if (uploadId) {
const cleanup = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
})
return singleCleanupResponse(cleanup)
}
const tempBaseDir = path.join(getUploadRootDir(), 'temp')
try {
const stats = await lstat(tempBaseDir)
if (stats.isSymbolicLink() || !stats.isDirectory()) {
return NextResponse.json(
{ error: '临时目录状态异常,已停止清理' },
{ status: 503 },
)
}
} catch (error: any) {
if (error?.code === 'ENOENT') {
return NextResponse.json({
message: '临时目录不存在,无需清理',
cleaned: 0,
freedSpace: 0,
skipped: 0,
})
}
throw error
}
const candidates = (await readdir(tempBaseDir)).slice(0, scanLimit)
let cleaned = 0
let totalFreedSpace = 0
let skipped = 0
const errors: string[] = []
const maxAgeMs = maxAgeHours * 60 * 60 * 1000
for (const candidate of candidates) {
if (!isSafeUploadId(candidate)) {
continue
}
// 路径检查只负责候选边界;helper 会在独占锁内重新 lstat/读 meta。
if (!resolveStrictChildPath(tempBaseDir, candidate)) {
skipped += 1
continue
}
const cleanup = await safelyCleanupMaterialUploadSession({
uploadId: candidate,
intent: 'sweep',
minAgeMs: maxAgeMs,
allowYoungOrphaned: !expiredOnly,
})
if (cleanup.success && cleanup.status === 'deleted') {
cleaned += cleanup.deletedFiles
totalFreedSpace += cleanup.freedSpace
continue
}
skipped += 1
if (cleanup.status === 'unavailable') {
errors.push(`${candidate}: ${cleanup.error || '状态无法确认,已保留数据'}`)
}
}
return NextResponse.json({
message: '清理完成',
cleaned,
freedSpace: totalFreedSpace,
freedSpaceFormatted: formatBytes(totalFreedSpace),
skipped,
errors: errors.length > 0 ? errors : undefined,
})
} catch (error: any) {
console.error('[Cleanup] GET Error:', error)
return NextResponse.json(
{ error: '清理失败: ' + (error.message || '未知错误') },
{ status: 503 },
)
}
}
/**
* POST - 当前用户取消自己的上传。候选 owner 可在锁外读取,但 helper 会在
* 独占锁内从 Redis/内存/meta.json 重新核对,且 completed 永远不能公开删除。
*/
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const currentUserId = String((session.user as any)?.id || '').trim()
if (!currentUserId) {
return NextResponse.json({ error: '登录会话缺少用户标识' }, { status: 401 })
}
const retryAfter = consumeRateLimit(`own:${currentUserId}`, 30)
if (retryAfter) {
return rateLimitResponse(retryAfter)
}
const body = await req.json()
const uploadId = typeof body?.uploadId === 'string' ? body.uploadId.trim() : ''
const existingFilePath =
typeof body?.existingFilePath === 'string' ? body.existingFilePath.trim() : ''
if (!uploadId && !existingFilePath) {
return NextResponse.json({ error: '缺少 uploadId 或 existingFilePath' }, { status: 400 })
}
if (uploadId && !isSafeUploadId(uploadId)) {
return NextResponse.json({ error: 'uploadId 格式无效' }, { status: 400 })
}
let effectiveUploadId = uploadId
if (existingFilePath) {
const derivedUploadId = deriveUploadIdCandidate(existingFilePath, uploadId || null)
if (!derivedUploadId) {
return NextResponse.json({ error: '合并文件路径无效' }, { status: 400 })
}
effectiveUploadId = derivedUploadId
}
const cleanup = await safelyCleanupMaterialUploadSession({
uploadId: effectiveUploadId,
intent: 'cancel',
expectedOwnerId: currentUserId,
})
return singleCleanupResponse(cleanup)
} catch (error: any) {
console.error('[Cleanup] POST Error:', error)
return NextResponse.json(
{ error: '清理失败: ' + (error.message || '未知错误') },
{ status: 503 },
)
}
}
function deriveUploadIdCandidate(
existingFilePath: string,
suppliedUploadId: string | null,
): string | null {
const tempRoot = path.resolve(getUploadRootDir(), 'temp')
const targetPath = path.resolve(existingFilePath)
const sessionDir = path.dirname(targetPath)
const derivedUploadId = path.basename(sessionDir)
if (
path.dirname(sessionDir) !== tempRoot
|| !path.basename(targetPath).startsWith('merged-')
|| !isSafeUploadId(derivedUploadId)
|| (suppliedUploadId && suppliedUploadId !== derivedUploadId)
) {
return null
}
return derivedUploadId
}
function singleCleanupResponse(cleanup: SafeMaterialUploadCleanupResult) {
if (!cleanup.success) {
return NextResponse.json(
{
error: cleanup.error || '该上传会话当前不能清理',
code: cleanup.code,
},
{ status: cleanup.httpStatus || (cleanup.status === 'unavailable' ? 503 : 409) },
)
}
return NextResponse.json({
message: cleanup.status === 'absent' ? '上传会话已清理' : '清理成功',
deletedFiles: cleanup.deletedFiles,
freedSpace: cleanup.freedSpace,
freedSpaceFormatted: formatBytes(cleanup.freedSpace),
})
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}