From 3b7eb4a539b48ff5b9c7af1c3dcba67082113cdc Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 28 May 2026 21:52:08 +0800 Subject: [PATCH] fix: harden image upload fallback handling --- app/(chat)/api/files/direct-upload/route.ts | 19 +++++- app/(chat)/api/files/upload/route.ts | 71 ++++++++++++++------- lib/client/upload-with-retry.ts | 41 +++++++++++- 3 files changed, 104 insertions(+), 27 deletions(-) diff --git a/app/(chat)/api/files/direct-upload/route.ts b/app/(chat)/api/files/direct-upload/route.ts index a8146f664..160f9e3c1 100644 --- a/app/(chat)/api/files/direct-upload/route.ts +++ b/app/(chat)/api/files/direct-upload/route.ts @@ -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", diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index a6ed88444..c78b7e253 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -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: { diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts index e351065fa..f262c4700 100644 --- a/lib/client/upload-with-retry.ts +++ b/lib/client/upload-with-retry.ts @@ -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 = { + 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;