Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getServerSession } from 'next-auth';
|
|
import { authOptions } from '@/lib/auth';
|
|
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';
|
|
import {
|
|
boundedMultipartRequestLimiter,
|
|
getBoundedMultipartFailure,
|
|
} from '@/lib/multipart-route-limits';
|
|
|
|
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) {
|
|
let releaseRequestSlot: (() => void) | null = null;
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
const userId = String((session?.user as { id?: string } | undefined)?.id || '').trim();
|
|
if (!userId) {
|
|
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire();
|
|
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 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 });
|
|
}
|
|
|
|
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 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 = buildStoredUploadPath('product-package', material.id, 'psd', fileName);
|
|
|
|
return NextResponse.json({ path: relativePath });
|
|
} catch (error) {
|
|
const boundedFailure = getBoundedMultipartFailure(error);
|
|
if (boundedFailure) {
|
|
return NextResponse.json(
|
|
{ error: boundedFailure.message, code: boundedFailure.code },
|
|
{
|
|
status: boundedFailure.status,
|
|
...(boundedFailure.retryAfterSeconds
|
|
? { headers: { 'Retry-After': String(boundedFailure.retryAfterSeconds) } }
|
|
: {}),
|
|
},
|
|
);
|
|
}
|
|
console.error('[PSD Upload] Error:', error);
|
|
return NextResponse.json(
|
|
{ error: '上传失败: ' + (error instanceof Error ? error.message : '未知错误') },
|
|
{ status: 500 }
|
|
);
|
|
} finally {
|
|
releaseRequestSlot?.();
|
|
}
|
|
}
|