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 { 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",
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user