fix(security): 收紧上传与素材文件操作

This commit is contained in:
chenpei 2026-07-30 22:49:00 +08:00
parent 7c913b6de9
commit b48872d83b
25 changed files with 2069 additions and 190 deletions

View File

@ -9,32 +9,23 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getAssetsByMaterialId, addAsset } from '@/lib/product-package';
import { getAssetsByMaterialId } from '@/lib/product-package';
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup';
import { createMaterialLog } from '@/lib/material-log';
import { normalizeDisplayImages } from '@/lib/material-cleanup';
import {
canUserViewMaterial,
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission';
import {
canManageMaterial,
type MaterialAuthorizationUser,
} from '@/lib/material-authorization';
type AssetRouteSessionUser = {
id: string;
role?: string | null;
type AssetRouteSessionUser = MaterialAuthorizationUser & {
department?: string | null;
managedDepartment?: string | null;
isSuperAdmin?: boolean | null;
};
async function canManageMaterial(user: AssetRouteSessionUser, materialUserId: string) {
if (materialUserId === user.id || isSystemAdmin(user.role) || user.isSuperAdmin === true) {
return true;
}
return isSuperAdmin(user.id);
}
async function canViewMaterial(
user: AssetRouteSessionUser,
material: { userId: string; materialType: string },
@ -131,77 +122,15 @@ export async function POST(
);
}
const userId = (session.user as any).id;
const materialId = params.id;
const body = await request.json();
const { assetType, fileUrl, fileName, fileSize, sortOrder } = body;
// 验证必填字段
if (!assetType || !fileUrl) {
// P0 止血:客户端提交的 fileUrl 不能作为服务端资产记录或删除依据。
// 资产只能由受控上传完成链路在服务端生成;恢复此入口前必须改为服务端签发的存储引用。
return NextResponse.json(
{ success: false, error: '缺少必填参数' },
{ status: 400 }
{
success: false,
error: '当前不支持直接注册素材文件地址,请通过受控上传流程添加资产',
},
{ status: 403 },
);
}
// 验证素材是否存在且属于当前用户
const material = await prisma.material.findUnique({
where: { id: materialId, deletedAt: null },
select: { id: true, userId: true },
});
if (!material) {
return NextResponse.json(
{ success: false, error: '素材不存在' },
{ status: 404 }
);
}
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material.userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
);
}
// 添加资产
const asset = await addAsset({
materialId,
assetType,
fileUrl,
fileName,
fileSize: fileSize ? BigInt(fileSize) : undefined,
sortOrder,
});
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
assetOperation: {
oldValue: null,
newValue: 'ADD',
},
assetType: {
oldValue: null,
newValue: asset.assetType,
},
assetFileName: {
oldValue: null,
newValue: asset.fileName || null,
},
assetFileUrl: {
oldValue: null,
newValue: asset.fileUrl,
},
},
});
return NextResponse.json({
success: true,
data: asset,
});
} catch (error) {
console.error('[materials/assets] POST error:', error);
return NextResponse.json(
@ -260,7 +189,7 @@ export async function DELETE(
);
}
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material.userId))) {
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material))) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
@ -332,6 +261,8 @@ export async function DELETE(
const cleanupResult = await deleteFileIfUnreferenced(asset.fileUrl, {
excludeAssetIds: [assetId],
// 历史资产 fileUrl 可能来自客户端,P0 阶段只删除数据库关联,不执行物理删除。
allowPhysicalDelete: false,
});
await createMaterialLog({

View File

@ -297,7 +297,10 @@ export async function DELETE(
}
const whiteBackgroundDirectory = getWhiteBackgroundRelativeDirectory(existingSku.materialId, skuId)
const whiteBackgroundDirectoryResult = await deleteLocalFile(whiteBackgroundDirectory)
const whiteBackgroundDirectoryResult = await deleteLocalFile(
whiteBackgroundDirectory,
{ allowDirectoryDelete: true },
)
const deletedFileCount = cleanupResults.filter((item) => item.success && !item.skipped).length
const skippedFileCount = cleanupResults.filter((item) => item.skipped).length

View File

@ -9,8 +9,19 @@ import {
} from '@/lib/standard-model-white-background'
import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
import { uploadStandardModelWhiteBackground } from '@/lib/standard-model-white-background-service'
import {
assertWhiteBackgroundUploadFiles,
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
import {
canManageMaterial,
type MaterialAuthorizationUser,
} from '@/lib/material-authorization'
export const runtime = 'nodejs'
const MAX_WHITE_BACKGROUND_REQUEST_BYTES =
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalBytes + 5 * 1024 * 1024
export async function POST(
request: NextRequest,
@ -26,7 +37,7 @@ export async function POST(
const userId = (session.user as any).id
const material = await prisma.material.findUnique({
where: { id: materialId },
where: { id: materialId, deletedAt: null },
select: {
id: true,
materialType: true,
@ -44,6 +55,18 @@ export async function POST(
return NextResponse.json({ error: '只有标准模型类型支持上传白底图' }, { status: 400 })
}
if (!(await canManageMaterial(session.user as MaterialAuthorizationUser, material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
const contentLength = Number(request.headers.get('content-length'))
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return NextResponse.json({ error: '缺少有效的请求大小' }, { status: 411 })
}
if (contentLength > MAX_WHITE_BACKGROUND_REQUEST_BYTES) {
return NextResponse.json({ error: '白底图上传请求过大' }, { status: 413 })
}
const formData = await request.formData()
const skuId = ((formData.get('skuId') as string) || '').trim()
const skuName = ((formData.get('skuName') as string) || '').trim()
@ -72,28 +95,45 @@ export async function POST(
return NextResponse.json({ error: 'SKU不存在或已被删除' }, { status: 404 })
}
for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) {
const entries = formData.getAll(view.fieldName)
if (entries.length > 1) {
return NextResponse.json(
{ error: `${view.label}只能上传 1 张图片` },
{ status: 400 },
)
}
const entry = entries[0]
if (entry !== undefined && typeof entry === 'string') {
return NextResponse.json(
{ error: `${view.label}不是有效图片文件` },
{ status: 400 },
)
}
}
const whiteBackgroundImages = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => {
acc[view.key] = formData.get(view.fieldName) as File | null
const entry = formData.get(view.fieldName)
acc[view.key] = (entry as File | undefined) || null
return acc
}, {} as Partial<Record<StandardModelWhiteBgKey, File | null>>)
const otherImages = formData
.getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME)
.filter((file): file is File => {
return (
!!file &&
typeof (file as File).name === 'string' &&
typeof (file as File).arrayBuffer === 'function' &&
Number((file as File).size || 0) > 0
)
})
const otherImageEntries = formData.getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME)
if (otherImageEntries.some((entry) => typeof entry === 'string')) {
return NextResponse.json({ error: '其他图片包含无效文件' }, { status: 400 })
}
const otherImages = otherImageEntries as File[]
const uploadedFixedViews = STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!whiteBackgroundImages[view.key])
if (uploadedFixedViews.length === 0 && otherImages.length === 0) {
return NextResponse.json(
{ error: '请至少上传 1 张白底图' },
{ status: 400 },
)
try {
await assertWhiteBackgroundUploadFiles({
fixedImages: whiteBackgroundImages,
otherImages,
})
} catch (error) {
if (error instanceof WhiteBackgroundUploadValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
throw error
}
const result = await uploadStandardModelWhiteBackground({

View File

@ -85,7 +85,9 @@ async function cleanupUploadSession(uploadId: string, uploadDir: string): Promis
// 使用统一的文件删除工具递归删除整个目录
const { deleteLocalFile } = await import('@/lib/file-cleanup')
const result = await deleteLocalFile(tempDir)
const result = await deleteLocalFile(tempDir, {
allowDirectoryDelete: true,
})
if (result.success) {
// 如果之前没有统计到文件,至少算删除了目录本身

View File

@ -3,7 +3,8 @@
import { POST } from '@/app/api/materials/upload/init/route'
import { getServerSession } from 'next-auth'
import { saveUploadSession } from '@/lib/upload-sessions'
import { mkdir, writeFile } from 'fs/promises'
import { ensureSafeDirectoryWithinRoot } from '@/lib/path-security'
import { writeFile } from 'fs/promises'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@ -17,8 +18,15 @@ jest.mock('@/lib/upload-sessions', () => ({
saveUploadSession: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('@/lib/path-security', () => {
const actual = jest.requireActual('@/lib/path-security')
return {
...actual,
ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue('D:\\temp\\uploads\\temp\\upload-fixed'),
}
})
jest.mock('fs/promises', () => ({
mkdir: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue(undefined),
}))
@ -94,7 +102,11 @@ describe('/api/materials/upload/init', () => {
expect(response.status).toBe(200)
expect(body).toEqual({ uploadId: expect.stringContaining('upload-') })
expect(mkdir).toHaveBeenCalled()
expect(ensureSafeDirectoryWithinRoot).toHaveBeenCalledWith(
expect.any(String),
'temp',
expect.stringContaining('upload-'),
)
expect(writeFile).toHaveBeenCalled()
expect(saveUploadSession).toHaveBeenCalledWith(
expect.stringContaining('upload-'),
@ -106,4 +118,40 @@ describe('/api/materials/upload/init', () => {
}),
)
})
test.each([
'../outside.psd',
'..\\outside.psd',
'/app/server.js',
'C:\\Windows\\system.ini',
'\\\\server\\share\\outside.psd',
'%2e%2e%2foutside.psd',
'%252e%252e%255coutside.psd',
'bad\u0000name.psd',
'folder/name.psd',
])('拒绝不安全文件名:%s', async (fileName) => {
const response = await POST(createRequest({
fileName,
fileSize: 1024,
totalChunks: 1,
}))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
error: '文件名包含不安全的路径或字符',
})
expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled()
expect(saveUploadSession).not.toHaveBeenCalled()
})
test('拒绝超过文件系统叶子名称上限的文件名', async () => {
const response = await POST(createRequest({
fileName: `${'文'.repeat(90)}.psd`,
fileSize: 1024,
totalChunks: 1,
}))
expect(response.status).toBe(400)
expect(saveUploadSession).not.toHaveBeenCalled()
})
})

View File

@ -1,8 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
import { saveUploadSession } from '@/lib/upload-sessions'
import { getUploadRootDir } from '@/lib/upload-paths'
import {
ensureSafeDirectoryWithinRoot,
normalizeSafeFileName,
} from '@/lib/path-security'
import { randomBytes } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { writeFile } from 'fs/promises'
import path from 'path'
export async function POST(req: NextRequest) {
@ -23,13 +28,17 @@ export async function POST(req: NextRequest) {
)
}
const normalizedFileName = typeof body?.fileName === 'string' ? body.fileName.trim() : ''
const rawFileName = typeof body?.fileName === 'string' ? body.fileName : ''
const normalizedFileName = normalizeSafeFileName(rawFileName)
const normalizedFileSize = Number(body?.fileSize)
const normalizedTotalChunks = Number(body?.totalChunks)
if (!normalizedFileName) {
if (!rawFileName.trim()) {
return NextResponse.json({ error: '文件名不能为空' }, { status: 400 })
}
if (!normalizedFileName) {
return NextResponse.json({ error: '文件名包含不安全的路径或字符' }, { status: 400 })
}
if (!Number.isFinite(normalizedFileSize) || normalizedFileSize <= 0) {
return NextResponse.json({ error: '上传文件不能为空' }, { status: 400 })
@ -43,9 +52,11 @@ export async function POST(req: NextRequest) {
const uploadId = `upload-${Date.now()}-${randomBytes(16).toString('hex')}`
// 创建临时目录
const uploadDir = process.env.UPLOAD_DIR || '/app/uploads'
const tempDir = path.join(uploadDir, 'temp', uploadId)
await mkdir(tempDir, { recursive: true })
const uploadDir = getUploadRootDir()
const tempDir = await ensureSafeDirectoryWithinRoot(uploadDir, 'temp', uploadId)
if (!tempDir) {
throw new Error('上传临时目录不安全')
}
// 写入元数据,方便在Redis丢失时回溯
const metaPath = path.join(tempDir, 'meta.json')
const metaPayload = {

View File

@ -2,8 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { deleteUploadSession, getUploadSession } from '@/lib/upload-sessions'
import path from 'path'
import {
deleteUploadSession,
getOwnedUploadSession,
isSafeUploadId,
} from '@/lib/upload-sessions'
import { getUploadRootDir } from '@/lib/upload-paths'
import {
isPathFreeOfSymbolicLinks,
resolveStrictChildPath,
} from '@/lib/path-security'
import { rm, unlink } from 'fs/promises'
interface AbortRequestBody {
@ -11,6 +19,58 @@ interface AbortRequestBody {
uploadIds?: string[]
}
interface ValidatedAbortUpload {
uploadId: string
assembledPath: string
tempDir: string
}
async function validateAbortUploads(
uploadIds: string[],
userId: string,
): Promise<ValidatedAbortUpload[]> {
const uploadRoot = getUploadRootDir()
const assembledRoot = resolveStrictChildPath(uploadRoot, 'assembled')
const tempRoot = resolveStrictChildPath(uploadRoot, 'temp')
if (!assembledRoot || !tempRoot) {
throw new Error('上传目录配置不安全')
}
const validated: ValidatedAbortUpload[] = []
for (const uploadId of uploadIds) {
const uploadSession = await getOwnedUploadSession(uploadId, userId)
if (!uploadSession) {
throw new Error(`上传会话不存在或不属于当前用户:${uploadId}`)
}
const assembledPath = resolveStrictChildPath(
assembledRoot,
`${uploadId}-${uploadSession.fileName}`,
)
const tempDir = resolveStrictChildPath(tempRoot, uploadId)
if (
!assembledPath ||
!tempDir ||
!(await isPathFreeOfSymbolicLinks(uploadRoot, assembledPath, {
allowMissingDescendants: true,
})) ||
!(await isPathFreeOfSymbolicLinks(uploadRoot, tempDir, {
allowMissingDescendants: true,
}))
) {
throw new Error(`上传会话路径不安全:${uploadId}`)
}
validated.push({
uploadId,
assembledPath,
tempDir,
})
}
return validated
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -21,9 +81,20 @@ export async function POST(req: NextRequest) {
const body = (await req.json()) as AbortRequestBody
const materialId = body.materialId?.trim()
const uploadIds = Array.isArray(body.uploadIds)
? body.uploadIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
: []
const rawUploadIds = Array.isArray(body.uploadIds) ? body.uploadIds : []
if (rawUploadIds.some((id) => typeof id !== 'string' || !isSafeUploadId(id))) {
return NextResponse.json(
{ success: false, error: 'uploadIds 包含非法上传会话 ID' },
{ status: 400 },
)
}
const uploadIds = Array.from(new Set(rawUploadIds.map((id) => id.trim())))
if (uploadIds.length > 200) {
return NextResponse.json(
{ success: false, error: '单次最多清理 200 个上传会话' },
{ status: 400 },
)
}
if (!materialId) {
return NextResponse.json({ success: false, error: '缺少materialId' }, { status: 400 })
@ -57,19 +128,29 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, deleted: false, reason: 'already_processed' })
}
for (const uploadId of uploadIds) {
const uploadSession = await getUploadSession(uploadId)
if (uploadSession?.fileName) {
const assembledPath = path.join(process.cwd(), 'uploads', 'assembled', `${uploadId}-${uploadSession.fileName}`)
await unlink(assembledPath).catch(() => undefined)
// 在执行任何文件、会话或素材删除之前,先一次性校验全部 uploadId、
// 会话存在性、所有权和物理路径边界,避免部分清理后才发现越权输入。
let validatedUploads: ValidatedAbortUpload[]
try {
validatedUploads = await validateAbortUploads(uploadIds, userId)
} catch (error) {
const message = error instanceof Error ? error.message : '上传会话校验失败'
return NextResponse.json({ success: false, error: message }, { status: 400 })
}
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId)
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
await deleteUploadSession(uploadId).catch(() => undefined)
for (const upload of validatedUploads) {
await unlink(upload.assembledPath).catch((error: any) => {
if (error?.code !== 'ENOENT') {
throw error
}
})
await rm(upload.tempDir, { recursive: true, force: true })
}
await prisma.material.delete({ where: { id: materialId } })
for (const upload of validatedUploads) {
await deleteUploadSession(upload.uploadId)
}
return NextResponse.json({
success: true,

View File

@ -0,0 +1,215 @@
/** @jest-environment node */
import { POST } from '@/app/api/upload/psd/route'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import {
ensureSafeDirectoryWithinRoot,
isPathFreeOfSymbolicLinks,
} from '@/lib/path-security'
import { canManageMaterial } from '@/lib/material-authorization'
import { writeFile } from 'fs/promises'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/material-authorization', () => ({
canManageMaterial: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
},
},
}))
jest.mock('@/lib/upload-paths', () => {
const actual = jest.requireActual('@/lib/upload-paths')
return {
...actual,
getUploadRootDir: jest.fn(() => 'D:\\safe-uploads'),
}
})
jest.mock('@/lib/path-security', () => {
const actual = jest.requireActual('@/lib/path-security')
return {
...actual,
ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue(
'D:\\safe-uploads\\product-package\\material-safe\\psd',
),
isPathFreeOfSymbolicLinks: jest.fn().mockResolvedValue(true),
}
})
jest.mock('fs/promises', () => ({
writeFile: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('crypto', () => ({
randomBytes: jest.fn(() => ({
toString: () => 'fixed-random',
})),
}))
function createPhotoshopFile(
name = 'source.psd',
options?: {
version?: 1 | 2
type?: string
reportedSize?: number
signature?: string
},
) {
const buffer = Buffer.alloc(16)
buffer.write(options?.signature ?? '8BPS', 0, 'ascii')
buffer.writeUInt16BE(options?.version ?? 1, 4)
return {
name,
type: options?.type ?? 'application/octet-stream',
size: options?.reportedSize ?? buffer.length,
arrayBuffer: jest.fn().mockResolvedValue(
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
),
}
}
function createRequest(input?: {
file?: ReturnType<typeof createPhotoshopFile> | null
materialId?: string
contentLength?: string | null
}) {
const values = new Map<string, unknown>([
['file', input?.file === undefined ? createPhotoshopFile() : input.file],
['materialId', input?.materialId ?? 'material-request'],
])
return {
headers: {
get: jest.fn((name: string) => (
name.toLowerCase() === 'content-length'
? (input?.contentLength === undefined ? '1024' : input.contentLength)
: null
)),
},
formData: jest.fn().mockResolvedValue({
get: (key: string) => values.get(key) ?? null,
}),
} as any
}
describe('/api/upload/psd', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1' },
})
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-safe',
userId: 'user-1',
deletedAt: null,
})
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
;(ensureSafeDirectoryWithinRoot as jest.Mock).mockResolvedValue(
'D:\\safe-uploads\\product-package\\material-safe\\psd',
)
;(isPathFreeOfSymbolicLinks as jest.Mock).mockResolvedValue(true)
})
test('缺少 Content-Length 时在解析 multipart 前拒绝请求', async () => {
const request = createRequest({ contentLength: null })
const response = await POST(request)
expect(response.status).toBe(411)
expect(request.formData).not.toHaveBeenCalled()
})
test('超大请求在解析 multipart 前返回 413', async () => {
const request = createRequest({ contentLength: String(131 * 1024 * 1024) })
const response = await POST(request)
expect(response.status).toBe(413)
expect(request.formData).not.toHaveBeenCalled()
})
test('不得向其他用户的素材目录上传 PSD', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-safe',
userId: 'user-2',
deletedAt: null,
})
;(canManageMaterial as jest.Mock).mockResolvedValue(false)
const response = await POST(createRequest())
expect(response.status).toBe(403)
expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled()
expect(writeFile).not.toHaveBeenCalled()
})
test('使用数据库返回的 material.id 构造受控目录', async () => {
const response = await POST(createRequest({ materialId: '../../client-path' }))
expect(response.status).toBe(200)
expect(prisma.material.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: '../../client-path' } }),
)
expect(ensureSafeDirectoryWithinRoot).toHaveBeenCalledWith(
'D:\\safe-uploads',
'product-package',
'material-safe',
'psd',
)
expect(writeFile).toHaveBeenCalledWith(
expect.stringContaining('material-safe'),
expect.any(Buffer),
{ flag: 'wx', mode: 0o600 },
)
await expect(response.json()).resolves.toEqual({
path: expect.stringMatching(
/^uploads\/product-package\/material-safe\/psd\/psd-\d+-fixed-random\.psd$/,
),
})
})
test('拒绝伪造扩展名或无效文件头', async () => {
const response = await POST(createRequest({
file: createPhotoshopFile('source.psd', { signature: 'FAKE' }),
}))
expect(response.status).toBe(400)
expect(writeFile).not.toHaveBeenCalled()
})
test('PSD 与 PSB 版本必须和扩展名一致', async () => {
const response = await POST(createRequest({
file: createPhotoshopFile('source.psd', { version: 2 }),
}))
expect(response.status).toBe(400)
expect(writeFile).not.toHaveBeenCalled()
})
test('拒绝不允许的 MIME 类型和超大单文件', async () => {
const badMimeResponse = await POST(createRequest({
file: createPhotoshopFile('source.psd', { type: 'text/plain' }),
}))
expect(badMimeResponse.status).toBe(400)
const oversizedResponse = await POST(createRequest({
file: createPhotoshopFile('source.psd', {
reportedSize: 129 * 1024 * 1024,
}),
}))
expect(oversizedResponse.status).toBe(413)
expect(writeFile).not.toHaveBeenCalled()
})
})

View File

@ -1,41 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { writeFile, mkdir } from 'fs/promises';
import { prisma } from '@/lib/prisma';
import {
canManageMaterial,
type MaterialAuthorizationUser,
} from '@/lib/material-authorization';
import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths';
import {
ensureSafeDirectoryWithinRoot,
isPathFreeOfSymbolicLinks,
normalizeSafeFileName,
resolveStrictChildPath,
} from '@/lib/path-security';
import { randomBytes } from 'crypto';
import { writeFile } from 'fs/promises';
import path from 'path';
const MAX_PSD_FILE_BYTES = 128 * 1024 * 1024;
const MAX_PSD_REQUEST_BYTES = MAX_PSD_FILE_BYTES + 2 * 1024 * 1024;
const ALLOWED_PSD_MIME_TYPES = new Set([
'',
'application/octet-stream',
'application/vnd.adobe.photoshop',
'application/x-photoshop',
'image/vnd.adobe.photoshop',
'image/x-photoshop',
]);
function detectPhotoshopDocumentType(buffer: Buffer): '.psd' | '.psb' | null {
if (buffer.length < 6 || buffer.subarray(0, 4).toString('ascii') !== '8BPS') {
return null;
}
const version = buffer.readUInt16BE(4);
if (version === 1) return '.psd';
if (version === 2) return '.psb';
return null;
}
// POST - 上传 PSD 文件到本地
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
const userId = String((session?.user as { id?: string } | undefined)?.id || '').trim();
if (!userId) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
const materialId = formData.get('materialId') as string;
const contentLength = Number(request.headers.get('content-length'));
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return NextResponse.json({ error: '缺少有效的请求大小' }, { status: 411 });
}
if (contentLength > MAX_PSD_REQUEST_BYTES) {
return NextResponse.json({ error: 'PSD 文件过大' }, { status: 413 });
}
if (!file || !materialId) {
const formData = await request.formData();
const file = formData.get('file');
const materialId = String(formData.get('materialId') || '').trim();
if (
!file ||
typeof (file as File).name !== 'string' ||
typeof (file as File).arrayBuffer !== 'function' ||
!materialId
) {
return NextResponse.json({ error: '缺少文件或素材ID' }, { status: 400 });
}
// 生成本地存储路径
const timestamp = Date.now();
const ext = file.name.split('.').pop();
const fileName = `psd-${timestamp}.${ext}`;
const uploadDir = path.join(process.cwd(), 'uploads', 'product-package', materialId, 'psd');
const filePath = path.join(uploadDir, fileName);
const uploadFile = file as File;
const safeOriginalFileName = normalizeSafeFileName(uploadFile.name);
const extension = path.extname(safeOriginalFileName || '').toLowerCase();
if (
!safeOriginalFileName ||
(extension !== '.psd' && extension !== '.psb') ||
!ALLOWED_PSD_MIME_TYPES.has(String(uploadFile.type || '').toLowerCase())
) {
return NextResponse.json({ error: '只支持有效的 PSD 或 PSB 文件' }, { status: 400 });
}
if (uploadFile.size <= 0 || uploadFile.size > MAX_PSD_FILE_BYTES) {
return NextResponse.json({ error: 'PSD 文件大小无效或超过限制' }, { status: 413 });
}
// 确保目录存在
await mkdir(uploadDir, { recursive: true });
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
userId: true,
deletedAt: true,
},
});
if (!material || material.deletedAt) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
if (
!(await canManageMaterial(
session!.user as MaterialAuthorizationUser,
material,
))
) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 });
}
if (normalizeSafeFileName(material.id) !== material.id) {
throw new Error('素材存储标识不安全');
}
// 保存文件
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
const buffer = Buffer.from(await uploadFile.arrayBuffer());
const detectedExtension = detectPhotoshopDocumentType(buffer);
if (!detectedExtension || detectedExtension !== extension) {
return NextResponse.json({ error: 'PSD 文件头或版本无效' }, { status: 400 });
}
const uploadRoot = getUploadRootDir();
const uploadDir = await ensureSafeDirectoryWithinRoot(
uploadRoot,
'product-package',
material.id,
'psd',
);
if (!uploadDir) {
throw new Error('PSD 上传目录不安全');
}
const fileName = `psd-${Date.now()}-${randomBytes(8).toString('hex')}${detectedExtension}`;
const filePath = resolveStrictChildPath(uploadDir, fileName);
if (
!filePath ||
!(await isPathFreeOfSymbolicLinks(uploadRoot, filePath, { allowMissingLeaf: true }))
) {
throw new Error('PSD 最终文件路径不安全');
}
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
// 返回相对路径
const relativePath = path.join('uploads', 'product-package', materialId, 'psd', fileName);
const relativePath = buildStoredUploadPath('product-package', material.id, 'psd', fileName);
return NextResponse.json({ path: relativePath });
} catch (error) {

View File

@ -116,7 +116,7 @@ describe('ProductPackageUploader', () => {
const input = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(input)
await user.type(input, '运营甲')
await user.click(screen.getByRole('button', { name: /运营甲/ }))
await user.click(screen.getByRole('option', { name: /运营甲/ }))
expect(input).toHaveValue('运营甲 (operator-a)')
expect(screen.getByText('记录发起本次出图需求的运营人员,与素材上传人可以不同。')).toBeInTheDocument()

View File

@ -0,0 +1,112 @@
/** @jest-environment node */
import {
access,
mkdtemp,
mkdir,
rm,
writeFile,
} from 'fs/promises'
import os from 'os'
import path from 'path'
import {
deleteFileIfUnreferenced,
deleteLocalFile,
} from '@/lib/file-cleanup'
jest.mock('@/lib/prisma', () => ({
prisma: {
materialAsset: { count: jest.fn() },
materialSku: { count: jest.fn() },
material: { count: jest.fn() },
},
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(),
isS3Url: jest.fn(() => false),
}))
describe('file cleanup security boundaries', () => {
let sandboxRoot: string
let uploadRoot: string
let previousUploadDir: string | undefined
beforeEach(async () => {
previousUploadDir = process.env.UPLOAD_DIR
sandboxRoot = await mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-cleanup-'))
uploadRoot = path.join(sandboxRoot, 'uploads')
await mkdir(uploadRoot, { recursive: true })
process.env.UPLOAD_DIR = uploadRoot
})
afterEach(async () => {
if (previousUploadDir === undefined) {
delete process.env.UPLOAD_DIR
} else {
process.env.UPLOAD_DIR = previousUploadDir
}
await rm(sandboxRoot, { recursive: true, force: true })
})
test('拒绝删除受控上传目录之外的文件', async () => {
const sentinelPath = path.join(sandboxRoot, 'sentinel.txt')
await writeFile(sentinelPath, 'keep')
const result = await deleteLocalFile(sentinelPath)
expect(result).toEqual({
success: false,
error: '拒绝删除受控上传目录之外的路径',
})
await expect(access(sentinelPath)).resolves.toBeUndefined()
})
test('允许删除受控上传目录内的普通文件', async () => {
const filePath = path.join(uploadRoot, 'material-1', 'preview.webp')
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, 'preview')
const result = await deleteLocalFile(filePath)
expect(result).toEqual({ success: true, isDirectory: false })
await expect(access(filePath)).rejects.toMatchObject({ code: 'ENOENT' })
})
test('目录默认不能递归删除,受信任调用方必须明确允许', async () => {
const directoryPath = path.join(uploadRoot, 'material-1')
const sentinelPath = path.join(directoryPath, 'sentinel.txt')
await mkdir(directoryPath, { recursive: true })
await writeFile(sentinelPath, 'keep')
const deniedResult = await deleteLocalFile(directoryPath)
expect(deniedResult).toEqual({
success: false,
isDirectory: true,
error: '目录递归删除必须由受信任调用方明确允许',
})
await expect(access(sentinelPath)).resolves.toBeUndefined()
const allowedResult = await deleteLocalFile(directoryPath, {
allowDirectoryDelete: true,
})
expect(allowedResult).toEqual({ success: true, isDirectory: true })
await expect(access(directoryPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
test('显式禁用物理删除时不触碰历史不可信 fileUrl', async () => {
const sentinelPath = path.join(sandboxRoot, 'legacy-target.txt')
await writeFile(sentinelPath, 'keep')
const result = await deleteFileIfUnreferenced(sentinelPath, {
allowPhysicalDelete: false,
})
expect(result).toEqual({
success: true,
skipped: true,
reason: 'physical_delete_disabled',
})
await expect(access(sentinelPath)).resolves.toBeUndefined()
})
})

View File

@ -3,6 +3,7 @@
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { addAsset, getAssetsByMaterialId } from '@/lib/product-package'
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
import {
DELETE,
GET,
@ -53,6 +54,11 @@ describe('material assets route authorization', () => {
zipFileSize: BigInt(0),
displayImages: [],
})
;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({
success: true,
skipped: true,
reason: 'physical_delete_disabled',
})
})
test('无查看权限的用户不得读取资产列表', async () => {
@ -79,4 +85,66 @@ describe('material assets route authorization', () => {
expect(response.status).toBe(403)
expect(prisma.materialAsset.findUnique).not.toHaveBeenCalled()
})
test('素材所有者也不能通过客户端 fileUrl 直接注册资产', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER', department: '设计部' },
})
const response = await POST({
json: jest.fn().mockResolvedValue({
assetType: 'preview',
fileUrl: 'D:\\app\\sentinel.txt',
}),
} as any, { params: { id: 'material-1' } })
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toContain('受控上传流程')
expect(addAsset).not.toHaveBeenCalled()
expect(prisma.material.findUnique).not.toHaveBeenCalled()
})
test('删除历史资产记录时不得根据不可信 fileUrl 执行物理删除', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER', department: '设计部' },
})
;(prisma.materialAsset.findUnique as jest.Mock).mockResolvedValue({
id: 'asset-1',
materialId: 'material-1',
assetType: 'preview',
fileUrl: 'D:\\app\\sentinel.txt',
fileName: 'sentinel.txt',
})
;(prisma.materialAsset.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
callback({
materialAsset: {
delete: jest.fn().mockResolvedValue({ id: 'asset-1' }),
},
material: {
update: jest.fn().mockResolvedValue({ id: 'material-1' }),
},
}),
)
const response = await DELETE({
url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1',
} as any, { params: { id: 'material-1' } })
const body = await response.json()
expect(response.status).toBe(200)
expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
'D:\\app\\sentinel.txt',
{
excludeAssetIds: ['asset-1'],
allowPhysicalDelete: false,
},
)
expect(body.details).toEqual({
fileDeleted: false,
fileSkipped: true,
fileCleanupError: null,
})
})
})

View File

@ -0,0 +1,63 @@
/** @jest-environment node */
import { canManageMaterial } from '@/lib/material-authorization'
import {
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission'
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn(),
isSystemAdmin: jest.fn(),
}))
describe('material authorization', () => {
beforeEach(() => {
jest.clearAllMocks()
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
})
test('素材所有者可以管理自己的素材', async () => {
await expect(
canManageMaterial(
{ id: 'user-1', role: 'USER' },
{ userId: 'user-1' },
),
).resolves.toBe(true)
expect(isSuperAdmin).not.toHaveBeenCalled()
})
test('系统管理员可以管理其他用户素材', async () => {
;(isSystemAdmin as jest.Mock).mockReturnValue(true)
await expect(
canManageMaterial(
{ id: 'admin-1', role: 'SYSTEM_ADMIN' },
{ userId: 'user-1' },
),
).resolves.toBe(true)
expect(isSuperAdmin).not.toHaveBeenCalled()
})
test('数据库中的超级管理员可以管理其他用户素材', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
await expect(
canManageMaterial(
{ id: 'super-1', role: 'USER' },
{ userId: 'user-1' },
),
).resolves.toBe(true)
expect(isSuperAdmin).toHaveBeenCalledWith('super-1')
})
test('普通非所有者不得管理素材', async () => {
await expect(
canManageMaterial(
{ id: 'user-2', role: 'USER' },
{ userId: 'user-1' },
),
).resolves.toBe(false)
})
})

View File

@ -13,7 +13,9 @@ import {
resolveSafePath,
isPathInAllowedDirs,
sanitizeFileName,
getAllowedFileDirectories
getAllowedFileDirectories,
normalizeSafeFileName,
resolveStrictChildPath,
} from '../path-security'
describe('R29: 路径安全工具', () => {
@ -143,4 +145,47 @@ describe('R29: 路径安全工具', () => {
expect(hasUploads).toBe(true)
})
})
describe('normalizeSafeFileName - 上传叶子文件名校验', () => {
it.each([
'../outside.psd',
'..\\outside.psd',
'/app/server.js',
'C:\\Windows\\system.ini',
'\\\\server\\share\\outside.psd',
'%2e%2e%2foutside.psd',
'%252e%252e%255coutside.psd',
'%25252e%25252e%25252foutside.psd',
'folder/name.psd',
'bad\u0000name.psd',
])('应该拒绝路径形式的文件名:%s', (fileName) => {
expect(normalizeSafeFileName(fileName)).toBeNull()
})
it('应该保留合法中文文件名并执行 trim', () => {
expect(normalizeSafeFileName(' 中文源文件.psb ')).toBe('中文源文件.psb')
})
it('应该按 UTF-8 字节数拒绝超长文件名', () => {
expect(normalizeSafeFileName(`${'文'.repeat(90)}.psd`)).toBeNull()
})
})
describe('resolveStrictChildPath - 严格子路径解析', () => {
const root = path.resolve('safe-upload-root')
it('应该只返回根目录下的严格子项', () => {
expect(resolveStrictChildPath(root, 'assembled', 'file.psd')).toBe(
path.join(root, 'assembled', 'file.psd'),
)
})
it('应该拒绝根目录本身和越界路径', () => {
expect(resolveStrictChildPath(root, '.')).toBeNull()
expect(resolveStrictChildPath(root, '..', 'outside.psd')).toBeNull()
expect(resolveStrictChildPath(root, path.parse(root).root, 'outside.psd')).toBeNull()
expect(resolveStrictChildPath(root, 'C:\\Windows\\system.ini')).toBeNull()
expect(resolveStrictChildPath(root, '\\\\server\\share\\outside.psd')).toBeNull()
})
})
})

View File

@ -0,0 +1,189 @@
/** @jest-environment node */
import { POST } from '@/app/api/materials/upload/product-package/abort/route'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import {
deleteUploadSession,
getOwnedUploadSession,
} from '@/lib/upload-sessions'
import { isPathFreeOfSymbolicLinks } from '@/lib/path-security'
import {
rm,
unlink,
} from 'fs/promises'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
delete: jest.fn(),
},
materialAsset: {
count: jest.fn(),
},
},
}))
jest.mock('@/lib/upload-sessions', () => ({
deleteUploadSession: jest.fn(),
getOwnedUploadSession: jest.fn(),
isSafeUploadId: jest.fn((uploadId: string) =>
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(String(uploadId || '').trim()),
),
}))
jest.mock('@/lib/path-security', () => {
const actual = jest.requireActual('@/lib/path-security')
return {
...actual,
isPathFreeOfSymbolicLinks: jest.fn(),
}
})
jest.mock('fs/promises', () => ({
rm: jest.fn(),
unlink: jest.fn(),
}))
describe('product-package abort route security', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1' },
})
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
previewUrl: '',
zipFilePath: '',
})
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
;(prisma.material.delete as jest.Mock).mockResolvedValue({ id: 'material-1' })
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
fileName: 'detail.zip',
fileSize: 1024,
totalChunks: 1,
chunks: new Map(),
createdAt: Date.now(),
userId: 'user-1',
})
;(deleteUploadSession as jest.Mock).mockResolvedValue(undefined)
;(isPathFreeOfSymbolicLinks as jest.Mock).mockResolvedValue(true)
;(unlink as jest.Mock).mockRejectedValue(
Object.assign(new Error('not found'), { code: 'ENOENT' }),
)
;(rm as jest.Mock).mockResolvedValue(undefined)
})
function createRequest(body: unknown) {
return {
json: jest.fn().mockResolvedValue(body),
} as any
}
test('非法 uploadId 应在查询会话和执行删除前整体拒绝', async () => {
const response = await POST(createRequest({
materialId: 'material-1',
uploadIds: ['../escape'],
}))
expect(response.status).toBe(400)
expect(getOwnedUploadSession).not.toHaveBeenCalled()
expect(unlink).not.toHaveBeenCalled()
expect(rm).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
test('任一会话不存在或不属于当前用户时不得部分清理', async () => {
;(getOwnedUploadSession as jest.Mock)
.mockResolvedValueOnce({
fileName: 'main.zip',
fileSize: 1024,
totalChunks: 1,
chunks: new Map(),
createdAt: Date.now(),
userId: 'user-1',
})
.mockResolvedValueOnce(null)
const response = await POST(createRequest({
materialId: 'material-1',
uploadIds: ['upload-1', 'upload-2'],
}))
expect(response.status).toBe(400)
expect(getOwnedUploadSession).toHaveBeenCalledTimes(2)
expect(unlink).not.toHaveBeenCalled()
expect(rm).not.toHaveBeenCalled()
expect(deleteUploadSession).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
test('单次上传会话数量超过上限时不得进入清理', async () => {
const response = await POST(createRequest({
materialId: 'material-1',
uploadIds: Array.from({ length: 201 }, (_, index) => `upload-${index}`),
}))
expect(response.status).toBe(400)
expect(getOwnedUploadSession).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
test('会话文件名导致组装路径越界时不得执行任何删除', async () => {
;(getOwnedUploadSession as jest.Mock).mockResolvedValueOnce({
fileName: `x${'\\..'.repeat(6)}\\sentinel.txt`,
fileSize: 1024,
totalChunks: 1,
chunks: new Map(),
createdAt: Date.now(),
userId: 'user-1',
})
const response = await POST(createRequest({
materialId: 'material-1',
uploadIds: ['upload-1'],
}))
expect(response.status).toBe(400)
expect(unlink).not.toHaveBeenCalled()
expect(rm).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
test('全部会话预校验通过后才清理受控目录并删除素材', async () => {
const response = await POST(createRequest({
materialId: 'material-1',
uploadIds: ['upload-1', 'upload-2'],
}))
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toEqual({
success: true,
deleted: true,
materialId: 'material-1',
cleanedUploadIds: 2,
})
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(1, 'upload-1', 'user-1')
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(2, 'upload-2', 'user-1')
expect(unlink).toHaveBeenCalledTimes(2)
expect(rm).toHaveBeenCalledTimes(2)
expect(prisma.material.delete).toHaveBeenCalledWith({ where: { id: 'material-1' } })
expect(deleteUploadSession).toHaveBeenCalledTimes(2)
for (const [targetPath] of (rm as jest.Mock).mock.calls) {
expect(targetPath).toContain(`uploads${require('path').sep}temp${require('path').sep}upload-`)
}
})
})

View File

@ -13,6 +13,8 @@ jest.mock('@aws-sdk/client-s3', () => ({
}))
describe('deleteFromS3', () => {
const originalS3FilePath = process.env.S3_FILE_PATH
beforeEach(() => {
jest.clearAllMocks()
process.env.S3_BUCKET = 'jsxiangche'
@ -21,6 +23,15 @@ describe('deleteFromS3', () => {
process.env.S3_REGION = 'us-east-1'
process.env.S3_ACCESS_KEY_ID = 'test-key'
process.env.S3_SECRET_ACCESS_KEY = 'test-secret'
delete process.env.S3_FILE_PATH
})
afterAll(() => {
if (originalS3FilePath === undefined) {
delete process.env.S3_FILE_PATH
} else {
process.env.S3_FILE_PATH = originalS3FilePath
}
})
test('删除公网 URL 时应解码中文路径并去掉 bucket 前缀', async () => {
@ -37,4 +48,27 @@ describe('deleteFromS3', () => {
})
expect(sendMock).toHaveBeenCalledTimes(1)
})
test('拒绝把任意 HTTP 域名解释为当前存储桶对象', async () => {
jest.resetModules()
const { deleteFromS3 } = await import('@/lib/s3')
await expect(
deleteFromS3('https://attacker.example.com/jsxiangche/other-user/file.png'),
).rejects.toThrow('拒绝不受信任的对象存储地址')
expect(deleteObjectCommandMock).not.toHaveBeenCalled()
expect(sendMock).not.toHaveBeenCalled()
})
test('配置业务前缀后拒绝删除前缀之外的对象', async () => {
process.env.S3_FILE_PATH = '由壹点AI/'
jest.resetModules()
const { deleteFromS3 } = await import('@/lib/s3')
await expect(
deleteFromS3('https://s3.1080p.hk/jsxiangche/other-user/file.png'),
).rejects.toThrow('拒绝删除当前业务前缀之外的对象')
expect(deleteObjectCommandMock).not.toHaveBeenCalled()
expect(sendMock).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,111 @@
/** @jest-environment node */
import sharp from 'sharp'
import {
assertWhiteBackgroundImageDimensions,
assertWhiteBackgroundUploadFiles,
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
describe('standard model white-background upload guard', () => {
async function createPngFile(name = 'white-bg.png') {
const buffer = await sharp({
create: {
width: 4,
height: 4,
channels: 3,
background: '#ffffff',
},
}).png().toBuffer()
return new File(
[buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer],
name,
{ type: 'image/png' },
)
}
test('接受单页且在大小和像素预算内的图片', async () => {
const file = await createPngFile()
await expect(
assertWhiteBackgroundUploadFiles({
fixedImages: { rightFront45: file },
otherImages: [],
}),
).resolves.toBeUndefined()
})
test('拒绝伪装成图片的不可解码内容', async () => {
const file = new File(
['not-an-image'],
'fake.png',
{ type: 'image/png' },
)
await expect(
assertWhiteBackgroundUploadFiles({
fixedImages: { rightFront45: file },
otherImages: [],
}),
).rejects.toThrow('无法读取或像素总量过大')
})
test('拒绝非白名单 MIME 类型', async () => {
const file = new File(
['image'],
'unsafe.svg',
{ type: 'image/svg+xml' },
)
await expect(
assertWhiteBackgroundUploadFiles({
fixedImages: { rightFront45: file },
otherImages: [],
}),
).rejects.toThrow('格式不支持')
})
test('拒绝超过其他图片数量上限的请求', async () => {
const files = Array.from(
{ length: STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxOtherImages + 1 },
(_, index) => new File(
['x'],
`other-${index}.png`,
{ type: 'image/png' },
),
)
await expect(
assertWhiteBackgroundUploadFiles({
fixedImages: {},
otherImages: files,
}),
).rejects.toThrow('其他图片最多上传')
})
test('在解码前拒绝超过单文件体积上限的图片', async () => {
const file = new File(
[new Uint8Array(STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxBytesPerImage + 1)],
'too-large.png',
{ type: 'image/png' },
)
await expect(
assertWhiteBackgroundUploadFiles({
fixedImages: { rightFront45: file },
otherImages: [],
}),
).rejects.toThrow('不能超过 20MB')
})
test('拒绝超过单图像素上限的尺寸', () => {
expect(() =>
assertWhiteBackgroundImageDimensions(
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage + 1,
1,
'测试图片',
),
).toThrow(WhiteBackgroundUploadValidationError)
})
})

View File

@ -0,0 +1,123 @@
/** @jest-environment node */
import path from 'path'
import os from 'os'
import {
mkdtemp,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from 'fs/promises'
import {
getUploadedFile,
saveUploadSession,
uploadSessions,
type UploadSession,
} from '@/lib/upload-sessions'
function createSession(fileName: string, fileSize: number): UploadSession {
return {
fileName,
fileSize,
totalChunks: 1,
chunks: new Map(),
createdAt: Date.now(),
userId: 'user-1',
}
}
describe('upload-sessions 最终落盘安全', () => {
let uploadRoot = ''
const previousUploadDir = process.env.UPLOAD_DIR
beforeEach(async () => {
uploadSessions.clear()
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'upload-session-security-'))
process.env.UPLOAD_DIR = uploadRoot
})
afterEach(async () => {
uploadSessions.clear()
if (uploadRoot) {
await rm(uploadRoot, { recursive: true, force: true })
}
if (previousUploadDir === undefined) {
delete process.env.UPLOAD_DIR
} else {
process.env.UPLOAD_DIR = previousUploadDir
}
})
async function writeSingleChunk(uploadId: string, content: Buffer) {
const sessionDir = path.join(uploadRoot, 'temp', uploadId)
await mkdir(sessionDir, { recursive: true })
await writeFile(path.join(sessionDir, 'chunk-0'), content)
}
test('合法会话只能在 assembled 目录生成普通文件', async () => {
const uploadId = 'upload-safe-file'
const content = Buffer.from('safe-content')
await writeSingleChunk(uploadId, content)
await saveUploadSession(uploadId, createSession('中文源文件.psd', content.length))
const outputPath = await getUploadedFile(uploadId)
expect(outputPath).toBe(
path.join(uploadRoot, 'assembled', `${uploadId}-中文源文件.psd`),
)
await expect(readFile(outputPath!)).resolves.toEqual(content)
})
test.each([
'../../../sentinel.txt',
'..\\..\\sentinel.txt',
'/app/server.js',
'C:\\Windows\\system.ini',
'\\\\server\\share\\outside.psd',
'bad\u0000name.psd',
])('历史恶意会话在最终落盘时仍被拒绝:%s', async (fileName) => {
const uploadId = `upload-malicious-${Math.random().toString(16).slice(2)}`
const sentinelPath = path.join(uploadRoot, 'sentinel.txt')
await writeFile(sentinelPath, 'sentinel')
await writeSingleChunk(uploadId, Buffer.from('attack'))
// 模拟修复前已经进入内存或 Redis 的旧会话,绕过新的 saveUploadSession 入口校验。
uploadSessions.set(uploadId, createSession(fileName, 6))
await expect(getUploadedFile(uploadId)).resolves.toBeNull()
await expect(readFile(sentinelPath, 'utf8')).resolves.toBe('sentinel')
})
test('saveUploadSession 自身拒绝不安全文件名', async () => {
await expect(
saveUploadSession(
'upload-invalid-session',
createSession('../outside.psd', 1),
),
).rejects.toThrow('上传会话包含不安全的标识或文件名')
})
test('最终目标是符号链接时不会跟随或覆盖链接目标', async () => {
const uploadId = 'upload-symlink-target'
const content = Buffer.from('attack')
await writeSingleChunk(uploadId, content)
await saveUploadSession(uploadId, createSession('source.psd', content.length))
const assembledDir = path.join(uploadRoot, 'assembled')
const sentinelDir = path.join(uploadRoot, 'sentinel-directory')
const sentinelPath = path.join(sentinelDir, 'sentinel.txt')
await mkdir(assembledDir)
await mkdir(sentinelDir)
await writeFile(sentinelPath, 'sentinel')
await symlink(
sentinelDir,
path.join(assembledDir, `${uploadId}-source.psd`),
'junction',
)
await expect(getUploadedFile(uploadId)).resolves.toBeNull()
await expect(readFile(sentinelPath, 'utf8')).resolves.toBe('sentinel')
})
})

View File

@ -7,6 +7,11 @@ import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
import { deleteFromS3 } from '@/lib/s3'
import { canManageMaterial } from '@/lib/material-authorization'
import {
assertWhiteBackgroundUploadFiles,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@ -16,6 +21,18 @@ jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/material-authorization', () => ({
canManageMaterial: jest.fn(),
}))
jest.mock('@/lib/standard-model-white-background-upload-guard', () => {
const actual = jest.requireActual('@/lib/standard-model-white-background-upload-guard')
return {
...actual,
assertWhiteBackgroundUploadFiles: jest.fn(),
}
})
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn().mockResolvedValue(undefined),
@ -95,6 +112,8 @@ describe('white-background upload route', () => {
categoryName: '旗舰店',
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'USER' })
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
;(assertWhiteBackgroundUploadFiles as jest.Mock).mockResolvedValue(undefined)
;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue({
id: 'sku-1',
skuName: 'XG014799',
@ -136,6 +155,7 @@ describe('white-background upload route', () => {
function createRequest(formData: FormData) {
return {
headers: new Headers({ 'content-length': '1024' }),
formData: jest.fn().mockResolvedValue(formData),
} as any
}
@ -200,6 +220,58 @@ describe('white-background upload route', () => {
expect(body).toEqual({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' })
})
test('非素材所有者或管理员应在读取 multipart 文件前返回 403', async () => {
;(canManageMaterial as jest.Mock).mockResolvedValue(false)
const request = {
formData: jest.fn(),
} as any
const response = await POST(request, {
params: { id: 'material-1' },
})
expect(response.status).toBe(403)
expect(request.formData).not.toHaveBeenCalled()
expect(assertWhiteBackgroundUploadFiles).not.toHaveBeenCalled()
})
test('图片资源门禁失败时应返回 400 且不进入存储服务', async () => {
const formData = new FormData()
formData.append('skuId', 'sku-1')
formData.append('skuName', 'XG014799')
formData.append('sourceType', '渲染')
formData.append(
'whiteBgRightFront45',
new File([Buffer.from('oversized')], 'oversized.png', { type: 'image/png' }),
)
;(assertWhiteBackgroundUploadFiles as jest.Mock).mockRejectedValueOnce(
new WhiteBackgroundUploadValidationError('图片「oversized.png」不能超过 20MB'),
)
const response = await POST(createRequest(formData), {
params: { id: 'material-1' },
})
const body = await response.json()
expect(response.status).toBe(400)
expect(body).toEqual({ error: '图片「oversized.png」不能超过 20MB' })
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('缺少请求大小时应在解析 multipart 前拒绝', async () => {
const request = {
headers: new Headers(),
formData: jest.fn(),
} as any
const response = await POST(request, {
params: { id: 'material-1' },
})
expect(response.status).toBe(411)
expect(request.formData).not.toHaveBeenCalled()
})
test('sourceType 非法时应返回 400', async () => {
const formData = new FormData()
formData.append('skuId', 'sku-1')

View File

@ -14,6 +14,11 @@ export interface DeleteFileResult {
error?: string;
}
export interface DeleteLocalFileOptions {
basePath?: string;
allowDirectoryDelete?: boolean;
}
function getFileUrlVariants(fileUrl: string): string[] {
const trimmed = fileUrl.trim();
if (!trimmed) {
@ -29,6 +34,43 @@ function getFileUrlVariants(fileUrl: string): string[] {
);
}
function getUploadRoot(): string {
return path.resolve(process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'));
}
function isPathStrictlyInside(basePath: string, targetPath: string): boolean {
const relativePath = path.relative(basePath, targetPath);
return (
relativePath.length > 0 &&
!relativePath.startsWith(`..${path.sep}`) &&
relativePath !== '..' &&
!path.isAbsolute(relativePath)
);
}
function resolveLocalDeleteTarget(filePath: string, basePath?: string): {
allowedRoot: string;
absolutePath: string;
} {
const allowedRoot = path.resolve(basePath || getUploadRoot());
const normalizedPath = String(filePath || '').trim().replace(/[\\/]+/g, path.sep);
const uploadsPrefix = `${path.sep}uploads${path.sep}`;
const relativeUploadsPrefix = `uploads${path.sep}`;
let absolutePath: string;
if (!basePath && normalizedPath.startsWith(uploadsPrefix)) {
absolutePath = path.resolve(allowedRoot, normalizedPath.slice(uploadsPrefix.length));
} else if (!basePath && normalizedPath.startsWith(relativeUploadsPrefix)) {
absolutePath = path.resolve(allowedRoot, normalizedPath.slice(relativeUploadsPrefix.length));
} else if (path.isAbsolute(normalizedPath)) {
absolutePath = path.resolve(normalizedPath);
} else {
absolutePath = path.resolve(basePath || process.cwd(), normalizedPath);
}
return { allowedRoot, absolutePath };
}
/**
* 删除单个文件或目录(统一入口)
* @param fileUrl 文件 URL(S3 URL 或本地路径)
@ -68,23 +110,24 @@ export async function deleteFile(
*/
export async function deleteLocalFile(
filePath: string,
basePath?: string
options?: string | DeleteLocalFileOptions,
): Promise<DeleteFileResult> {
try {
// 处理路径
let absolutePath: string;
if (path.isAbsolute(filePath)) {
absolutePath = filePath;
} else if (filePath.startsWith('/')) {
absolutePath = filePath;
} else {
absolutePath = path.join(basePath || process.cwd(), filePath);
const basePath = typeof options === 'string' ? options : options?.basePath;
const allowDirectoryDelete =
typeof options === 'object' && options?.allowDirectoryDelete === true;
const { allowedRoot, absolutePath } = resolveLocalDeleteTarget(filePath, basePath);
if (!isPathStrictlyInside(allowedRoot, absolutePath)) {
return {
success: false,
error: '拒绝删除受控上传目录之外的路径',
};
}
// 检查文件/目录是否存在
let stats;
try {
stats = await fs.stat(absolutePath);
stats = await fs.lstat(absolutePath);
} catch (err: any) {
if (err.code === 'ENOENT') {
// 文件/目录不存在,不算错误
@ -93,9 +136,33 @@ export async function deleteLocalFile(
throw err;
}
if (stats.isSymbolicLink()) {
return {
success: false,
error: '拒绝删除符号链接',
};
}
const [realAllowedRoot, realTargetPath] = await Promise.all([
fs.realpath(allowedRoot),
fs.realpath(absolutePath),
]);
if (!isPathStrictlyInside(realAllowedRoot, realTargetPath)) {
return {
success: false,
error: '拒绝删除受控上传目录之外的路径',
};
}
// 根据类型删除
if (stats.isDirectory()) {
// 目录:递归删除
if (!allowDirectoryDelete) {
return {
success: false,
isDirectory: true,
error: '目录递归删除必须由受信任调用方明确允许',
};
}
await fs.rm(absolutePath, { recursive: true, force: true });
return { success: true, isDirectory: true };
} else {
@ -221,8 +288,17 @@ export async function deleteFileIfUnreferenced(
excludeSkuIds?: string[];
excludeMaterialIds?: string[];
basePath?: string;
allowPhysicalDelete?: boolean;
}
): Promise<DeleteFileResult & { skipped?: boolean; reason?: string }> {
if (options?.allowPhysicalDelete === false) {
return {
success: true,
skipped: true,
reason: 'physical_delete_disabled',
};
}
const referenced = await hasActiveFileReferences(fileUrl, options);
if (referenced) {
return {

View File

@ -0,0 +1,37 @@
import {
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission'
export interface MaterialAuthorizationUser {
id: string
role?: string | null
isSuperAdmin?: boolean | null
}
export interface MaterialAuthorizationTarget {
userId: string
}
/**
* 素材写操作的最小统一授权:
* - 素材所有者
* - 系统管理员(兼容 ADMIN / SYSTEM_ADMIN)
* - 超级管理员
*
* 部门查看权限不能自动升级为素材写权限。
*/
export async function canManageMaterial(
user: MaterialAuthorizationUser,
material: MaterialAuthorizationTarget,
): Promise<boolean> {
if (
material.userId === user.id ||
isSystemAdmin(user.role) ||
user.isSuperAdmin === true
) {
return true
}
return isSuperAdmin(user.id)
}

View File

@ -10,6 +10,212 @@
*/
import path from 'path'
import { lstat, mkdir } from 'fs/promises'
export const MAX_SAFE_FILE_NAME_BYTES = 255
function decodePathForValidation(value: string): string {
let decoded = value
for (let index = 0; index < 5; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) {
break
}
decoded = next
} catch {
break
}
}
return decoded
}
/**
* 验证客户端文件名是单一叶子文件名,而不是路径。
* 返回 trim + NFC 后的文件名;不安全时返回 null。
*/
export function normalizeSafeFileName(
fileName: string,
maxBytes: number = MAX_SAFE_FILE_NAME_BYTES,
): string | null {
if (typeof fileName !== 'string') {
return null
}
const normalized = fileName.trim().normalize('NFC')
if (
!normalized ||
normalized === '.' ||
normalized === '..' ||
Buffer.byteLength(normalized, 'utf8') > maxBytes
) {
return null
}
const decoded = decodePathForValidation(normalized)
const candidates = decoded === normalized ? [normalized] : [normalized, decoded]
for (const candidate of candidates) {
if (
path.posix.isAbsolute(candidate) ||
path.win32.isAbsolute(candidate) ||
/^[A-Za-z]:/.test(candidate) ||
candidate.startsWith('\\\\') ||
candidate.includes('/') ||
candidate.includes('\\') ||
candidate.includes('..') ||
/[\x00-\x1f\x7f]/.test(candidate) ||
/[<>:"|?*]/.test(candidate)
) {
return null
}
}
return normalized
}
/**
* 将路径解析为 basePath 的严格子项。不能返回 basePath 本身。
*/
export function resolveStrictChildPath(
basePath: string,
...segments: string[]
): string | null {
if (
!basePath ||
segments.length === 0 ||
segments.some((segment) => (
!segment ||
segment === '.' ||
segment === '..' ||
segment.includes('/') ||
segment.includes('\\') ||
path.posix.isAbsolute(segment) ||
path.win32.isAbsolute(segment) ||
/^[A-Za-z]:/.test(segment)
))
) {
return null
}
const normalizedBase = path.resolve(basePath)
const targetPath = path.resolve(normalizedBase, ...segments)
const relativePath = path.relative(normalizedBase, targetPath)
if (
!relativePath ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
return null
}
return targetPath
}
/**
* 在可信根目录下逐级创建目录,并拒绝任何已存在的符号链接或非目录节点。
*/
export async function ensureSafeDirectoryWithinRoot(
basePath: string,
...segments: string[]
): Promise<string | null> {
const targetPath = resolveStrictChildPath(basePath, ...segments)
if (!targetPath) {
return null
}
const normalizedBase = path.resolve(basePath)
await mkdir(normalizedBase, { recursive: true })
const baseStats = await lstat(normalizedBase)
if (baseStats.isSymbolicLink() || !baseStats.isDirectory()) {
return null
}
let currentPath = normalizedBase
for (const segment of segments) {
const nextPath = resolveStrictChildPath(currentPath, segment)
if (!nextPath || path.relative(normalizedBase, nextPath).startsWith(`..${path.sep}`)) {
return null
}
try {
const stats = await lstat(nextPath)
if (stats.isSymbolicLink() || !stats.isDirectory()) {
return null
}
} catch (error: any) {
if (error?.code !== 'ENOENT') {
throw error
}
await mkdir(nextPath)
const createdStats = await lstat(nextPath)
if (createdStats.isSymbolicLink() || !createdStats.isDirectory()) {
return null
}
}
currentPath = nextPath
}
return currentPath === targetPath ? currentPath : null
}
/**
* 检查 basePath 到 targetPath 的所有现存节点是否都不是符号链接。
*/
export async function isPathFreeOfSymbolicLinks(
basePath: string,
targetPath: string,
options?: {
allowMissingLeaf?: boolean
allowMissingDescendants?: boolean
},
): Promise<boolean> {
const normalizedBase = path.resolve(basePath)
const normalizedTarget = path.resolve(targetPath)
const relativePath = path.relative(normalizedBase, normalizedTarget)
if (
!relativePath ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
return false
}
const baseStats = await lstat(normalizedBase).catch(() => null)
if (!baseStats || baseStats.isSymbolicLink() || !baseStats.isDirectory()) {
return false
}
const parts = relativePath.split(path.sep).filter(Boolean)
let currentPath = normalizedBase
for (let index = 0; index < parts.length; index += 1) {
currentPath = path.join(currentPath, parts[index])
const stats = await lstat(currentPath).catch((error: any) => {
if (error?.code === 'ENOENT') {
return null
}
throw error
})
if (!stats) {
return (
options?.allowMissingDescendants === true ||
(options?.allowMissingLeaf === true && index === parts.length - 1)
)
}
if (stats.isSymbolicLink()) {
return false
}
}
return true
}
/**
* 检查路径是否包含路径遍历字符

View File

@ -18,6 +18,90 @@ export function getS3KeyFromUrl(fileUrl: string): string {
return key
}
function decodeObjectKeyForValidation(value: string): string {
let decoded = value
for (let index = 0; index < 5; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) break
decoded = next
} catch {
break
}
}
return decoded
}
function normalizeObjectPrefix(value: string | undefined): string {
return String(value || '')
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
}
/**
* 删除属于高风险写操作,只接受当前配置的公网域名或 S3 Endpoint。
* 同时限制到当前业务前缀,拒绝把任意 HTTP URL 当作本桶对象 Key。
*/
export function getTrustedS3MutationKey(fileUrl: string): string {
const target = new URL(fileUrl)
if (
!['http:', 'https:'].includes(target.protocol) ||
target.username ||
target.password
) {
throw new Error('拒绝不受信任的对象存储地址')
}
const configuredUrls = [
process.env.S3_PUBLIC_DOMAIN,
cleanS3Endpoint(process.env.S3_ENDPOINT),
]
.map((value) => String(value || '').trim())
.filter(Boolean)
.map((value) => new URL(value))
const trustedBase = configuredUrls.find((configured) => {
if (configured.origin !== target.origin) {
return false
}
const basePath = configured.pathname.replace(/\/+$/g, '')
return (
!basePath ||
basePath === '/' ||
target.pathname === basePath ||
target.pathname.startsWith(`${basePath}/`)
)
})
if (!trustedBase) {
throw new Error('拒绝不受信任的对象存储地址')
}
const key = decodeObjectKeyForValidation(getS3KeyFromUrl(fileUrl))
.replace(/^\/+/g, '')
const keySegments = key.split('/')
if (
!key ||
key.includes('\\') ||
/[\x00-\x1f\x7f]/.test(key) ||
keySegments.some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new Error('拒绝不安全的对象存储 Key')
}
const requiredPrefix = normalizeObjectPrefix(process.env.S3_FILE_PATH)
if (
requiredPrefix &&
key !== requiredPrefix &&
!key.startsWith(`${requiredPrefix}/`)
) {
throw new Error('拒绝删除当前业务前缀之外的对象')
}
return key
}
/**
* 判断 URL 是否为 HTTP/HTTPS URL
*/
@ -30,7 +114,13 @@ export function isHttpUrl(url: string): boolean {
* 判断 URL 是否为 S3 URL
*/
export function isS3Url(url: string): boolean {
return isHttpUrl(url)
if (!isHttpUrl(url)) return false
try {
getTrustedS3MutationKey(url)
return true
} catch {
return false
}
}
/**
@ -195,7 +285,7 @@ export async function downloadFromS3(fileUrl: string): Promise<Buffer> {
export async function deleteFromS3(fileUrl: string): Promise<void> {
try {
const key = getS3KeyFromUrl(fileUrl)
const key = getTrustedS3MutationKey(fileUrl)
const command = new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,

View File

@ -0,0 +1,157 @@
import sharp from 'sharp'
import type {
StandardModelWhiteBgKey,
} from '@/lib/standard-model-white-background'
export const STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS = {
maxOtherImages: 12,
maxTotalImages: 20,
maxBytesPerImage: 20 * 1024 * 1024,
maxTotalBytes: 100 * 1024 * 1024,
maxPixelsPerImage: 40_000_000,
maxTotalPixels: 160_000_000,
} as const
const ALLOWED_IMAGE_MIME_TYPES = new Set([
'image/avif',
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
])
const ALLOWED_IMAGE_FORMATS = new Set([
'avif',
'jpeg',
'png',
'webp',
])
export class WhiteBackgroundUploadValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'WhiteBackgroundUploadValidationError'
}
}
export function assertWhiteBackgroundImageDimensions(
width: number,
height: number,
label = '图片',
): number {
if (
!Number.isInteger(width) ||
!Number.isInteger(height) ||
width <= 0 ||
height <= 0
) {
throw new WhiteBackgroundUploadValidationError(`${label}尺寸无效`)
}
const pixels = width * height
if (pixels > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage) {
throw new WhiteBackgroundUploadValidationError(`${label}像素总量过大`)
}
return pixels
}
function assertUploadFile(value: File, label: string): void {
if (
!value ||
typeof value.name !== 'string' ||
typeof value.arrayBuffer !== 'function' ||
!Number.isFinite(value.size) ||
value.size <= 0
) {
throw new WhiteBackgroundUploadValidationError(`${label}不是有效图片文件`)
}
if (!ALLOWED_IMAGE_MIME_TYPES.has(String(value.type || '').toLowerCase())) {
throw new WhiteBackgroundUploadValidationError(
`${label}格式不支持,仅允许 JPG、PNG、WebP 或 AVIF`,
)
}
if (value.size > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxBytesPerImage) {
throw new WhiteBackgroundUploadValidationError(`${label}不能超过 20MB`)
}
}
export async function assertWhiteBackgroundUploadFiles(input: {
fixedImages: Partial<Record<StandardModelWhiteBgKey, File | null>>
otherImages: File[]
}): Promise<void> {
if (input.otherImages.length > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxOtherImages) {
throw new WhiteBackgroundUploadValidationError(
`其他图片最多上传 ${STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxOtherImages} 张`,
)
}
const files = [
...Object.values(input.fixedImages).filter((file): file is File => Boolean(file)),
...input.otherImages,
]
if (files.length === 0) {
throw new WhiteBackgroundUploadValidationError('请至少上传 1 张白底图')
}
if (files.length > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalImages) {
throw new WhiteBackgroundUploadValidationError(
`单次最多上传 ${STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalImages} 张白底图`,
)
}
let totalBytes = 0
for (let index = 0; index < files.length; index++) {
const file = files[index]
const label = `图片「${file.name || index + 1}」`
assertUploadFile(file, label)
totalBytes += file.size
}
if (totalBytes > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalBytes) {
throw new WhiteBackgroundUploadValidationError('单次上传白底图总体积不能超过 100MB')
}
let totalPixels = 0
for (let index = 0; index < files.length; index++) {
const file = files[index]
const label = `图片「${file.name || index + 1}」`
try {
const buffer = Buffer.from(await file.arrayBuffer())
const metadata = await sharp(buffer, {
limitInputPixels: STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage,
sequentialRead: true,
failOn: 'error',
}).metadata()
if (
!metadata.width ||
!metadata.height ||
!metadata.format ||
!ALLOWED_IMAGE_FORMATS.has(metadata.format)
) {
throw new WhiteBackgroundUploadValidationError(`${label}不是支持的图片`)
}
if (Number(metadata.pages || 1) > 1) {
throw new WhiteBackgroundUploadValidationError(`${label}不支持动图或多页图片`)
}
const pixels = assertWhiteBackgroundImageDimensions(
metadata.width,
metadata.height,
label,
)
totalPixels += pixels
if (totalPixels > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalPixels) {
throw new WhiteBackgroundUploadValidationError('单次上传白底图总像素量过大')
}
} catch (error) {
if (error instanceof WhiteBackgroundUploadValidationError) {
throw error
}
throw new WhiteBackgroundUploadValidationError(
`${label}无法读取或像素总量过大`,
)
}
}
}

View File

@ -22,6 +22,13 @@
import path from 'path'
import { lstat, readFile } from 'fs/promises'
import {
ensureSafeDirectoryWithinRoot,
isPathFreeOfSymbolicLinks,
normalizeSafeFileName,
resolveStrictChildPath,
} from './path-security'
import { getUploadRootDir } from './upload-paths'
export interface UploadSession {
fileName: string
@ -157,7 +164,7 @@ export function isSafeUploadId(uploadId: string): boolean {
}
function getUploadRoot(): string {
return path.resolve(process.env.UPLOAD_DIR || './uploads')
return getUploadRootDir()
}
function isPathInside(basePath: string, targetPath: string): boolean {
@ -181,7 +188,9 @@ async function getUploadSessionFromMetadata(uploadId: string): Promise<UploadSes
const createdAt = Number(metadata?.createdAt)
const fileSize = Number(metadata?.fileSize)
const totalChunks = Number(metadata?.totalChunks)
const fileName = typeof metadata?.fileName === 'string' ? metadata.fileName : ''
const fileName = typeof metadata?.fileName === 'string'
? normalizeSafeFileName(metadata.fileName)
: null
const userId = typeof metadata?.userId === 'string' ? metadata.userId : ''
const ageMs = Date.now() - createdAt
@ -265,10 +274,20 @@ export async function saveUploadSession(
uploadId: string,
session: UploadSession
): Promise<void> {
setMemorySession(uploadId, session)
const normalizedUploadId = String(uploadId || '').trim()
const normalizedFileName = normalizeSafeFileName(session.fileName)
if (!isSafeUploadId(normalizedUploadId) || !normalizedFileName) {
throw new Error('上传会话包含不安全的标识或文件名')
}
const normalizedSession = {
...session,
fileName: normalizedFileName,
}
setMemorySession(normalizedUploadId, normalizedSession)
await withRedisFallback(
'saveUploadSession',
() => redisModule!.saveUploadSession(uploadId, session),
() => redisModule!.saveUploadSession(normalizedUploadId, normalizedSession),
async () => {}
)
}
@ -311,7 +330,12 @@ export async function getOwnedUploadSession(
(await getUploadSession(normalizedUploadId)) ||
(await getUploadSessionFromMetadata(normalizedUploadId))
return session?.userId === normalizedUserId ? session : null
return (
session?.userId === normalizedUserId &&
normalizeSafeFileName(session.fileName) !== null
)
? session
: null
}
export async function getOwnedUploadedFile(
@ -393,38 +417,69 @@ export async function resolveOwnedMergedUpload(input: {
* 返回组装后的完整文件路径
*/
export async function getUploadedFile(uploadId: string): Promise<string | null> {
const session = await getUploadSession(uploadId);
const normalizedUploadId = String(uploadId || '').trim()
if (!isSafeUploadId(normalizedUploadId)) {
return null
}
const session = await getUploadSession(normalizedUploadId);
if (!session) {
return null;
}
const path = await import('path');
const { readdir, readFile, mkdir, access, open, stat } = await import('fs/promises');
const safeFileName = normalizeSafeFileName(session.fileName)
if (!safeFileName) {
console.warn(`[Upload Session] Unsafe file name rejected: ${normalizedUploadId}`)
return null
}
// Chunk文件所在目录
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId);
const { readdir, readFile, open, lstat, unlink } = await import('fs/promises');
const uploadRoot = getUploadRoot()
const tempDir = resolveStrictChildPath(uploadRoot, 'temp', normalizedUploadId)
const outputDir = await ensureSafeDirectoryWithinRoot(uploadRoot, 'assembled')
if (!tempDir || !outputDir) {
console.warn(`[Upload Session] Unsafe upload directory rejected: ${normalizedUploadId}`)
return null
}
// 输出目录和路径
const outputDir = path.join(process.env.UPLOAD_DIR || './uploads', 'assembled');
const outputPath = path.join(outputDir, `${uploadId}-${session.fileName}`);
const outputPath = resolveStrictChildPath(
outputDir,
`${normalizedUploadId}-${safeFileName}`,
)
if (
!outputPath ||
!(await isPathFreeOfSymbolicLinks(uploadRoot, tempDir)) ||
!(await isPathFreeOfSymbolicLinks(uploadRoot, outputPath, { allowMissingLeaf: true }))
) {
console.warn(`[Upload Session] Unsafe upload path rejected: ${normalizedUploadId}`)
return null
}
let outputCreated = false
try {
// 先检查文件是否已经存在(避免重复组装)
try {
await access(outputPath);
const existingStats = await stat(outputPath);
const existingStats = await lstat(outputPath);
if (existingStats.isSymbolicLink() || !existingStats.isFile()) {
console.warn(`[Upload Session] Existing output is not a regular file: ${outputPath}`)
return null
}
if (existingStats.size === session.fileSize) {
console.log(`[Upload Session] File already exists, skip assembly: ${outputPath}`);
return outputPath;
}
} catch (e) {
// 文件不存在,需要组装
await unlink(outputPath)
} catch (error: any) {
if (error?.code !== 'ENOENT') {
throw error
}
}
// 读取所有chunk文件
const files = await readdir(tempDir);
const chunkFiles = files
.filter(f => f.startsWith('chunk-'))
.filter(f => /^chunk-\d+$/.test(f))
.sort((a, b) => {
const indexA = parseInt(a.replace('chunk-', ''));
const indexB = parseInt(b.replace('chunk-', ''));
@ -441,14 +496,16 @@ export async function getUploadedFile(uploadId: string): Promise<string | null>
return null;
}
// 创建输出目录
await mkdir(outputDir, { recursive: true });
// 分片逐块写入,避免大文件合并时把所有分片同时堆进内存。
const outputHandle = await open(outputPath, 'w');
const outputHandle = await open(outputPath, 'wx');
outputCreated = true
try {
for (const chunkFile of chunkFiles) {
const chunkPath = path.join(tempDir, chunkFile);
const chunkStats = await lstat(chunkPath)
if (chunkStats.isSymbolicLink() || !chunkStats.isFile()) {
throw new Error(`上传分片不是普通文件: ${chunkFile}`)
}
const chunkBuffer = await readFile(chunkPath);
await outputHandle.write(chunkBuffer);
}
@ -456,18 +513,27 @@ export async function getUploadedFile(uploadId: string): Promise<string | null>
await outputHandle.close();
}
const outputStats = await stat(outputPath);
if (outputStats.size !== session.fileSize) {
const outputStats = await lstat(outputPath);
if (
outputStats.isSymbolicLink() ||
!outputStats.isFile() ||
outputStats.size !== session.fileSize
) {
console.warn(
`[Upload Session] Assembled size mismatch: ${uploadId}, expected=${session.fileSize}, actual=${outputStats.size}`,
`[Upload Session] Assembled size mismatch: ${normalizedUploadId}, expected=${session.fileSize}, actual=${outputStats.size}`,
);
await unlink(outputPath).catch(() => undefined)
outputCreated = false
return null;
}
console.log(`[Upload Session] Assembled file: ${outputPath} (${outputStats.size} bytes)`);
return outputPath;
} catch (error) {
console.error(`[Upload Session] Failed to get uploaded file for ${uploadId}:`, error);
if (outputCreated) {
await unlink(outputPath).catch(() => undefined)
}
console.error(`[Upload Session] Failed to get uploaded file for ${normalizedUploadId}:`, error);
return null;
}
}