fix: harden image upload fallback handling
This commit is contained in:
parent
fc04267f74
commit
3b7eb4a539
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user