410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { z } from "zod";
|
||
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
||
import { upsertMediaAsset } from "@/lib/db/queries";
|
||
import { isSiteAdminType } from "@/lib/middleware/admin";
|
||
import { getContentType, uploadFile } from "@/lib/storage/service";
|
||
import { createLogger } from "@/lib/observability/logger";
|
||
|
||
const logger = createLogger("upload-api");
|
||
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||
"image/jpeg",
|
||
"image/jpg",
|
||
"image/png",
|
||
"image/webp",
|
||
"image/svg+xml",
|
||
"image/heic",
|
||
"image/heif",
|
||
]);
|
||
|
||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||
|
||
function normalizeUploadContentType(contentType: string, filename: string) {
|
||
const normalized = contentType.trim().toLowerCase();
|
||
if (normalized && normalized !== "application/octet-stream") {
|
||
return normalized;
|
||
}
|
||
|
||
return getContentType(filename);
|
||
}
|
||
|
||
function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
|
||
const extension = filename.toLowerCase().split(".").pop() || "unknown";
|
||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
||
}
|
||
|
||
return "File type should be JPEG, PNG, WebP or SVG";
|
||
}
|
||
|
||
function isHeicUpload(contentType: string, filename: string) {
|
||
const extension = filename.toLowerCase().split(".").pop() || "";
|
||
return (
|
||
HEIC_UPLOAD_TYPES.has(contentType) ||
|
||
extension === "heic" ||
|
||
extension === "heif"
|
||
);
|
||
}
|
||
|
||
function toJpegFilename(filename: string) {
|
||
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
||
if (/\.(heic|heif)$/i.test(trimmed)) {
|
||
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
|
||
}
|
||
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
||
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
||
}
|
||
return `${trimmed}.jpg`;
|
||
}
|
||
|
||
async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
||
const convert = (await import("heic-convert")).default;
|
||
const output = await convert({
|
||
buffer,
|
||
format: "JPEG",
|
||
quality: 0.92,
|
||
});
|
||
if (output instanceof ArrayBuffer) {
|
||
return Buffer.from(new Uint8Array(output));
|
||
}
|
||
return Buffer.from(output);
|
||
}
|
||
/**
|
||
* 使用Sharp智能优化图片(保持原格式)
|
||
* 策略:避免双重压缩,只在需要时优化
|
||
* - 如果文件已经很小(<3MB),跳过优化(客户端已处理)
|
||
* - 如果文件较大,使用MozJPEG/PNG优化算法进一步优化
|
||
*/
|
||
async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer> {
|
||
try {
|
||
const fileSizeMB = buffer.length / 1024 / 1024;
|
||
const OPTIMIZE_THRESHOLD_MB = 3; // 3MB阈值:小于此值不优化(避免双重压缩)
|
||
|
||
// ✅ 智能判断:如果文件已经很小,跳过优化(避免双重压缩)
|
||
if (fileSizeMB < OPTIMIZE_THRESHOLD_MB) {
|
||
logger.debug(`[Upload] 文件已较小(${fileSizeMB.toFixed(2)}MB < ${OPTIMIZE_THRESHOLD_MB}MB),跳过优化,避免双重压缩`);
|
||
return buffer;
|
||
}
|
||
|
||
const sharp = await import("sharp");
|
||
const image = sharp.default(buffer);
|
||
|
||
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
|
||
// ✅ 使用MozJPEG编码器,高质量压缩
|
||
// 注意:这里使用MozJPEG重新编码,使用更好的算法,而不是降低质量
|
||
const optimized = await image
|
||
.jpeg({
|
||
quality: 92, // 高质量(92%质量,画质优秀)
|
||
mozjpeg: true, // 使用MozJPEG编码器(比标准JPEG压缩率高15-25%,但质量更好)
|
||
progressive: true, // 渐进式JPEG,加载体验更好
|
||
optimizeScans: true, // 优化扫描
|
||
trellisQuantisation: true, // 网格量化,更好的压缩
|
||
overshootDeringing: true, // 过冲去振铃,减少伪影
|
||
optimizeCoding: true, // 优化编码
|
||
})
|
||
.toBuffer();
|
||
|
||
// 只有优化后文件明显更小才使用(避免不必要的处理)
|
||
const reductionPercent = ((1 - optimized.length / buffer.length) * 100);
|
||
if (reductionPercent > 5) { // 只有减少超过5%才使用
|
||
logger.debug(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
|
||
return optimized;
|
||
} else {
|
||
logger.debug(`[Upload] JPEG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
|
||
return buffer;
|
||
}
|
||
} else if (mimeType === 'image/png') {
|
||
// ✅ PNG优化压缩(无损优化)
|
||
const metadata = await image.metadata();
|
||
const optimized = await image
|
||
.png({
|
||
compressionLevel: 6, // 压缩级别(0-9,6是平衡点)
|
||
adaptiveFiltering: true, // 自适应过滤,减少文件大小
|
||
palette: !metadata.hasAlpha, // 无透明通道时使用调色板模式
|
||
quality: 100, // PNG质量(100=无损)
|
||
effort: 6, // 压缩努力程度(0-10,6是平衡点)
|
||
})
|
||
.toBuffer();
|
||
|
||
// 只有优化后文件明显更小才使用
|
||
const reductionPercent = ((1 - optimized.length / buffer.length) * 100);
|
||
if (reductionPercent > 5) { // 只有减少超过5%才使用
|
||
logger.debug(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
|
||
return optimized;
|
||
} else {
|
||
logger.debug(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
|
||
return buffer;
|
||
}
|
||
}
|
||
|
||
// 其他格式返回原图
|
||
return buffer;
|
||
} catch (error) {
|
||
logger.error("[Upload] 图片优化失败,使用原图:", error);
|
||
// 优化失败时返回原图,不影响上传流程
|
||
return buffer;
|
||
}
|
||
}
|
||
|
||
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||
return view.slice().buffer;
|
||
}
|
||
|
||
// Use Blob instead of File since File is not available in Node.js environment
|
||
const FileSchema = z.object({
|
||
file: z
|
||
.instanceof(Blob)
|
||
.refine((file) => file.size <= 50 * 1024 * 1024, {
|
||
message: "File size should be less than 50MB",
|
||
}),
|
||
});
|
||
|
||
export async function POST(request: Request) {
|
||
logger.debug("[Upload API] Received upload request");
|
||
|
||
const session = await auth();
|
||
|
||
if (!session) {
|
||
logger.debug("[Upload API] Unauthorized - no session");
|
||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
}
|
||
|
||
if (request.body === null) {
|
||
logger.debug("[Upload API] Request body is empty");
|
||
return new Response("Request body is empty", { status: 400 });
|
||
}
|
||
|
||
try {
|
||
const formData = await request.formData();
|
||
const file = formData.get("file") as Blob;
|
||
|
||
logger.debug("[Upload API] FormData received, file exists:", !!file);
|
||
|
||
if (!file) {
|
||
logger.debug("[Upload API] No file in FormData");
|
||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||
}
|
||
|
||
// Get filename from formData since Blob doesn't have name property
|
||
const originalFilename = (formData.get("file") as File).name;
|
||
const originalContentType = normalizeUploadContentType(
|
||
file.type,
|
||
originalFilename
|
||
);
|
||
let filename = originalFilename;
|
||
let contentType = originalContentType;
|
||
const uploadPurpose = String(formData.get("purpose") || "");
|
||
const clientUploadId =
|
||
String(formData.get("clientUploadId") || "").trim() || null;
|
||
const isSvgUpload = contentType === "image/svg+xml";
|
||
if (
|
||
isSvgUpload &&
|
||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
||
) {
|
||
return NextResponse.json(
|
||
{ error: "SVG uploads are only allowed for administrator icon settings" },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
logger.debug("[Upload API] File info:", {
|
||
type: contentType,
|
||
size: file.size,
|
||
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
|
||
});
|
||
|
||
const validatedFile = FileSchema.safeParse({ file });
|
||
|
||
if (!validatedFile.success) {
|
||
const errorMessage = validatedFile.error.errors
|
||
.map((error) => error.message)
|
||
.join(", ");
|
||
|
||
logger.debug("[Upload API] Validation failed:", errorMessage);
|
||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||
}
|
||
|
||
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
|
||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||
logger.warn("[Upload API] Unsupported upload content type", {
|
||
contentType,
|
||
filenameExtension: filename.toLowerCase().split(".").pop() || null,
|
||
size: file.size,
|
||
purpose: uploadPurpose || null,
|
||
clientUploadId,
|
||
});
|
||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||
}
|
||
|
||
logger.debug("[Upload API] Filename:", filename);
|
||
|
||
const fileBuffer = await file.arrayBuffer();
|
||
logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength);
|
||
|
||
try {
|
||
let uploadBuffer: Buffer = Buffer.from(fileBuffer);
|
||
let convertedFrom: string | null = null;
|
||
|
||
if (isHeicUpload(contentType, filename)) {
|
||
try {
|
||
const convertedBuffer = await convertHeicToJpeg(uploadBuffer);
|
||
logger.info("[Upload API] HEIC image converted to JPEG", {
|
||
clientUploadId,
|
||
originalContentType: contentType,
|
||
originalSize: uploadBuffer.length,
|
||
convertedSize: convertedBuffer.length,
|
||
});
|
||
uploadBuffer = convertedBuffer;
|
||
filename = toJpegFilename(filename);
|
||
convertedFrom = contentType;
|
||
contentType = "image/jpeg";
|
||
} catch (heicError) {
|
||
logger.warn("[Upload API] HEIC image conversion failed", {
|
||
clientUploadId,
|
||
contentType,
|
||
size: uploadBuffer.length,
|
||
error:
|
||
heicError instanceof Error ? heicError.message : String(heicError),
|
||
});
|
||
return NextResponse.json(
|
||
{
|
||
error: createUnsupportedImageTypeMessage(contentType, filename),
|
||
clientUploadId,
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
}
|
||
|
||
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
|
||
const preview = await createAgentWorkflowPreviewImageBuffer(uploadBuffer);
|
||
const previewResult = await uploadFile(
|
||
`${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`,
|
||
toArrayBuffer(preview.buffer)
|
||
);
|
||
|
||
let assetId: string | null = null;
|
||
try {
|
||
const asset = await upsertMediaAsset({
|
||
userId: session.user.id,
|
||
mediaType: "image",
|
||
sourceType: "agent_workflow_preview_upload",
|
||
status: "ready",
|
||
originalUrl: previewResult.url,
|
||
previewUrl: previewResult.url,
|
||
thumbnailUrl: previewResult.url,
|
||
mimeType: "image/webp",
|
||
width: preview.width,
|
||
height: preview.height,
|
||
displayName: filename,
|
||
sourceTable: "agent_workflow_preview_upload",
|
||
metadata: {
|
||
pathname: previewResult.pathname,
|
||
originalSize: fileBuffer.byteLength,
|
||
previewSize: preview.buffer.length,
|
||
purpose: uploadPurpose,
|
||
clientUploadId,
|
||
convertedFrom,
|
||
},
|
||
});
|
||
assetId = asset?.id ?? null;
|
||
} catch (assetError) {
|
||
logger.warn("[Upload API] Failed to register workflow preview asset:", assetError);
|
||
}
|
||
|
||
return NextResponse.json({
|
||
...previewResult,
|
||
url: previewResult.url,
|
||
previewUrl: previewResult.url,
|
||
thumbnailUrl: previewResult.url,
|
||
originalUrl: previewResult.url,
|
||
previewImageSourceUrl: previewResult.url,
|
||
assetId,
|
||
width: preview.width,
|
||
height: preview.height,
|
||
optimizedFor: uploadPurpose,
|
||
});
|
||
}
|
||
|
||
// ✅ 使用Sharp优化图片(保持原格式)
|
||
let optimizedBuffer: Buffer = uploadBuffer;
|
||
|
||
// ✅ 优化原图(保持JPG/PNG格式)
|
||
// WebP格式不优化(用于预览图,保持原样)
|
||
const shouldOptimizeImage =
|
||
uploadPurpose !== "canvas-upload" &&
|
||
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
|
||
|
||
if (shouldOptimizeImage) {
|
||
optimizedBuffer = await optimizeImage(optimizedBuffer, contentType);
|
||
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
|
||
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
|
||
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
|
||
} else if (uploadPurpose === "canvas-upload") {
|
||
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
|
||
}
|
||
|
||
// 将Buffer转换为ArrayBuffer用于上传
|
||
const optimizedArrayBuffer = optimizedBuffer.buffer.slice(
|
||
optimizedBuffer.byteOffset,
|
||
optimizedBuffer.byteOffset + optimizedBuffer.byteLength
|
||
) as ArrayBuffer;
|
||
|
||
// 使用统一的存储服务(支持数据库配置的 S3、环境变量 S3 或 Vercel Blob)
|
||
// 使用优化后的buffer上传
|
||
const data = await uploadFile(filename, optimizedArrayBuffer);
|
||
|
||
let assetId: string | null = null;
|
||
try {
|
||
const asset = await upsertMediaAsset({
|
||
userId: session.user.id,
|
||
mediaType: "image",
|
||
sourceType: "upload",
|
||
status: "ready",
|
||
originalUrl: data.url,
|
||
previewUrl: data.url,
|
||
thumbnailUrl: data.url,
|
||
mimeType: data.contentType || contentType || null,
|
||
displayName: filename,
|
||
sourceTable: "upload",
|
||
metadata: {
|
||
pathname: data.pathname,
|
||
size: optimizedBuffer.length,
|
||
clientUploadId,
|
||
convertedFrom,
|
||
},
|
||
});
|
||
assetId = asset?.id ?? null;
|
||
} catch (assetError) {
|
||
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
|
||
}
|
||
|
||
logger.debug("[Upload API] Upload successful:", data.url);
|
||
return NextResponse.json({
|
||
...data,
|
||
assetId,
|
||
clientUploadId,
|
||
convertedFrom,
|
||
});
|
||
} catch (_error) {
|
||
logger.error("[Upload API] Upload failed:", _error);
|
||
return NextResponse.json({
|
||
error: "Upload failed",
|
||
details: _error instanceof Error ? _error.message : String(_error)
|
||
}, { status: 500 });
|
||
}
|
||
} catch (_error) {
|
||
logger.error("[Upload API] Request processing failed:", _error);
|
||
return NextResponse.json(
|
||
{
|
||
error: "Failed to process request",
|
||
details: _error instanceof Error ? _error.message : String(_error)
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|