Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
262 lines
8.7 KiB
TypeScript
262 lines
8.7 KiB
TypeScript
// 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]
|
||
}
|