fix: harden image upload fallback handling

This commit is contained in:
Your Name 2026-05-28 21:52:08 +08:00
parent fc04267f74
commit 3b7eb4a539
3 changed files with 104 additions and 27 deletions

View File

@ -12,6 +12,7 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import {
buildS3PublicUrl,
buildStorageObjectKey,
@ -21,6 +22,7 @@ import {
resolveS3UploadTarget,
} from "@/lib/storage/service";
const logger = createLogger("direct-upload-api");
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
@ -155,7 +157,7 @@ export async function POST(request: Request) {
}
const target = await resolveS3UploadTarget().catch((error) => {
console.error("[DirectUpload] Failed to resolve storage target:", error);
logger.error("resolve_storage_target_failed", error);
return null;
});
@ -177,6 +179,12 @@ export async function POST(request: Request) {
);
if (!isAllowedDirectUpload(contentType)) {
logger.warn("unsupported_direct_upload_type", {
contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
size: payload.size,
purpose: payload.purpose || null,
});
return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" },
{ status: 400 }
@ -335,7 +343,7 @@ export async function POST(request: Request) {
storageConfig: target.storageConfig,
migrationConfig: target.migrationConfig,
}).catch((error) => {
console.error("[DirectUpload] Failed to enqueue OSS migration task:", error);
logger.warn("enqueue_storage_migration_failed", error);
});
return NextResponse.json({
@ -345,7 +353,12 @@ export async function POST(request: Request) {
assetId: asset?.id ?? null,
});
} catch (error) {
console.error("[DirectUpload] Request failed:", error);
logger.error("direct_upload_request_failed", error, {
action: payload.action,
size: "size" in payload ? payload.size : null,
contentType: "contentType" in payload ? payload.contentType : null,
purpose: "purpose" in payload ? payload.purpose || null : null,
});
return NextResponse.json(
{
error: "Direct upload failed",

View File

@ -5,10 +5,35 @@ 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 { uploadFile } from "@/lib/storage/service";
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",
]);
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";
}
/**
* 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
@ -97,17 +122,7 @@ const FileSchema = z.object({
.instanceof(Blob)
.refine((file) => file.size <= 50 * 1024 * 1024, {
message: "File size should be less than 50MB",
})
// Update the file type based on the kind of files you want to accept
.refine(
(file) =>
["image/jpeg", "image/png", "image/webp", "image/svg+xml"].includes(
file.type
),
{
message: "File type should be JPEG, PNG, WebP or SVG",
}
),
}),
});
export async function POST(request: Request) {
@ -136,8 +151,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name;
const contentType = normalizeUploadContentType(file.type, filename);
const uploadPurpose = String(formData.get("purpose") || "");
const isSvgUpload = file.type === "image/svg+xml";
const isSvgUpload = contentType === "image/svg+xml";
if (
isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
@ -149,7 +167,7 @@ export async function POST(request: Request) {
}
logger.debug("[Upload API] File info:", {
type: file.type,
type: contentType,
size: file.size,
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
});
@ -165,15 +183,24 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name;
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,
});
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 {
if (uploadPurpose === "agent-workflow-preview" && file.type !== "image/svg+xml") {
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
const preview = await createAgentWorkflowPreviewImageBuffer(Buffer.from(fileBuffer));
const previewResult = await uploadFile(
`${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`,
@ -228,13 +255,13 @@ export async function POST(request: Request) {
// WebP格式不优化(用于预览图,保持原样)
const shouldOptimizeImage =
uploadPurpose !== "canvas-upload" &&
(file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png');
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
if (shouldOptimizeImage) {
optimizedBuffer = await optimizeImage(optimizedBuffer, file.type);
} else if (file.type === 'image/webp' || file.type === 'image/svg+xml') {
optimizedBuffer = await optimizeImage(optimizedBuffer, contentType);
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
logger.debug(`[Upload] ${file.type}格式,跳过优化,直接上传`);
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
} else if (uploadPurpose === "canvas-upload") {
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
}
@ -259,7 +286,7 @@ export async function POST(request: Request) {
originalUrl: data.url,
previewUrl: data.url,
thumbnailUrl: data.url,
mimeType: data.contentType || file.type || null,
mimeType: data.contentType || contentType || null,
displayName: filename,
sourceTable: "upload",
metadata: {

View File

@ -3,6 +3,7 @@ const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3;
const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024;
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
@ -23,6 +24,13 @@ const DIRECT_UPLOAD_IMAGE_TYPES = new Set([
"image/webp",
]);
const BROWSER_IMAGE_TYPES_BY_EXTENSION: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
};
let activeUploadCount = 0;
const uploadQueue: Array<() => void> = [];
let directUploadSuppressedUntil = 0;
@ -160,6 +168,16 @@ function cloneFormData(formData: FormData) {
return copy;
}
function normalizeBrowserImageType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase();
if (normalized && normalized !== "application/octet-stream") {
return normalized;
}
const extension = filename.toLowerCase().split(".").pop() || "";
return BROWSER_IMAGE_TYPES_BY_EXTENSION[extension] || normalized;
}
function getUploadFormFile(formData: FormData): UploadFormFile | null {
const value = formData.get("file");
if (!(value instanceof Blob)) {
@ -170,7 +188,10 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
typeof File !== "undefined" && value instanceof File && value.name
? value.name
: `upload-${Date.now()}`;
const contentType = value.type || "application/octet-stream";
const contentType = normalizeBrowserImageType(
value.type || "application/octet-stream",
filename
);
const purposeValue = formData.get("purpose");
return {
@ -182,7 +203,12 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
}
function shouldTryDirectUpload(file: UploadFormFile) {
if (Date.now() < directUploadSuppressedUntil) return false;
if (
Date.now() < directUploadSuppressedUntil &&
file.blob.size <= SERVER_UPLOAD_MAX_BYTES
) {
return false;
}
if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) {
return false;
}
@ -408,6 +434,17 @@ async function fetchDirectUpload(
}
if (!isAbortError(error, signal)) {
if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) {
return Response.json(
{
error:
"图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
details: error instanceof Error ? error.message : String(error),
},
{ status: 502 }
);
}
console.warn("[Upload] Direct upload failed; falling back to server upload", error);
suppressDirectUploadTemporarily();
return null;