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 { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries"; import { upsertMediaAsset } from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import { import {
buildS3PublicUrl, buildS3PublicUrl,
buildStorageObjectKey, buildStorageObjectKey,
@ -21,6 +22,7 @@ import {
resolveS3UploadTarget, resolveS3UploadTarget,
} from "@/lib/storage/service"; } from "@/lib/storage/service";
const logger = createLogger("direct-upload-api");
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 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) => { const target = await resolveS3UploadTarget().catch((error) => {
console.error("[DirectUpload] Failed to resolve storage target:", error); logger.error("resolve_storage_target_failed", error);
return null; return null;
}); });
@ -177,6 +179,12 @@ export async function POST(request: Request) {
); );
if (!isAllowedDirectUpload(contentType)) { 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( return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" }, { error: "Direct upload only supports JPG, PNG and WebP images" },
{ status: 400 } { status: 400 }
@ -335,7 +343,7 @@ export async function POST(request: Request) {
storageConfig: target.storageConfig, storageConfig: target.storageConfig,
migrationConfig: target.migrationConfig, migrationConfig: target.migrationConfig,
}).catch((error) => { }).catch((error) => {
console.error("[DirectUpload] Failed to enqueue OSS migration task:", error); logger.warn("enqueue_storage_migration_failed", error);
}); });
return NextResponse.json({ return NextResponse.json({
@ -345,7 +353,12 @@ export async function POST(request: Request) {
assetId: asset?.id ?? null, assetId: asset?.id ?? null,
}); });
} catch (error) { } 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( return NextResponse.json(
{ {
error: "Direct upload failed", 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 { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
import { upsertMediaAsset } from "@/lib/db/queries"; import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin"; 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"; import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("upload-api"); 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智能优化图片(保持原格式) * 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化 * 策略:避免双重压缩,只在需要时优化
@ -97,17 +122,7 @@ const FileSchema = z.object({
.instanceof(Blob) .instanceof(Blob)
.refine((file) => file.size <= 50 * 1024 * 1024, { .refine((file) => file.size <= 50 * 1024 * 1024, {
message: "File size should be less than 50MB", 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) { 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 }); 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 uploadPurpose = String(formData.get("purpose") || "");
const isSvgUpload = file.type === "image/svg+xml"; const isSvgUpload = contentType === "image/svg+xml";
if ( if (
isSvgUpload && isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type)) (uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
@ -149,7 +167,7 @@ export async function POST(request: Request) {
} }
logger.debug("[Upload API] File info:", { logger.debug("[Upload API] File info:", {
type: file.type, type: contentType,
size: file.size, size: file.size,
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB" 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 }); return NextResponse.json({ error: errorMessage }, { status: 400 });
} }
// Get filename from formData since Blob doesn't have name property if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
const filename = (formData.get("file") as File).name; 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); logger.debug("[Upload API] Filename:", filename);
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength); logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength);
try { 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 preview = await createAgentWorkflowPreviewImageBuffer(Buffer.from(fileBuffer));
const previewResult = await uploadFile( const previewResult = await uploadFile(
`${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`, `${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`,
@ -228,13 +255,13 @@ export async function POST(request: Request) {
// WebP格式不优化(用于预览图,保持原样) // WebP格式不优化(用于预览图,保持原样)
const shouldOptimizeImage = const shouldOptimizeImage =
uploadPurpose !== "canvas-upload" && 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) { if (shouldOptimizeImage) {
optimizedBuffer = await optimizeImage(optimizedBuffer, file.type); optimizedBuffer = await optimizeImage(optimizedBuffer, contentType);
} else if (file.type === 'image/webp' || file.type === 'image/svg+xml') { } else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。 // WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
logger.debug(`[Upload] ${file.type}格式,跳过优化,直接上传`); logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
} else if (uploadPurpose === "canvas-upload") { } else if (uploadPurpose === "canvas-upload") {
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop"); 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, originalUrl: data.url,
previewUrl: data.url, previewUrl: data.url,
thumbnailUrl: data.url, thumbnailUrl: data.url,
mimeType: data.contentType || file.type || null, mimeType: data.contentType || contentType || null,
displayName: filename, displayName: filename,
sourceTable: "upload", sourceTable: "upload",
metadata: { metadata: {

View File

@ -3,6 +3,7 @@ const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3; const MAX_CONCURRENT_UPLOADS = 3;
const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024; const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024;
const DIRECT_UPLOAD_MAX_BYTES = 500 * 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_PART_CONCURRENCY = 4;
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000; const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
@ -23,6 +24,13 @@ const DIRECT_UPLOAD_IMAGE_TYPES = new Set([
"image/webp", "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; let activeUploadCount = 0;
const uploadQueue: Array<() => void> = []; const uploadQueue: Array<() => void> = [];
let directUploadSuppressedUntil = 0; let directUploadSuppressedUntil = 0;
@ -160,6 +168,16 @@ function cloneFormData(formData: FormData) {
return copy; 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 { function getUploadFormFile(formData: FormData): UploadFormFile | null {
const value = formData.get("file"); const value = formData.get("file");
if (!(value instanceof Blob)) { if (!(value instanceof Blob)) {
@ -170,7 +188,10 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
typeof File !== "undefined" && value instanceof File && value.name typeof File !== "undefined" && value instanceof File && value.name
? value.name ? value.name
: `upload-${Date.now()}`; : `upload-${Date.now()}`;
const contentType = value.type || "application/octet-stream"; const contentType = normalizeBrowserImageType(
value.type || "application/octet-stream",
filename
);
const purposeValue = formData.get("purpose"); const purposeValue = formData.get("purpose");
return { return {
@ -182,7 +203,12 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
} }
function shouldTryDirectUpload(file: UploadFormFile) { 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) { if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) {
return false; return false;
} }
@ -408,6 +434,17 @@ async function fetchDirectUpload(
} }
if (!isAbortError(error, signal)) { 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); console.warn("[Upload] Direct upload failed; falling back to server upload", error);
suppressDirectUploadTemporarily(); suppressDirectUploadTemporarily();
return null; return null;