Your Name e1e53713b5
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add direct multipart image uploads
2026-05-27 10:54:45 +08:00

355 lines
9.4 KiB
TypeScript

import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
HeadObjectCommand,
PutObjectCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries";
import {
buildS3PublicUrl,
buildStorageObjectKey,
createS3Client,
getContentType,
maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget,
} from "@/lib/storage/service";
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
const allowedDirectUploadTypes = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
]);
const InitiateSchema = z.object({
action: z.literal("initiate"),
filename: z.string().min(1).max(256),
contentType: z.string().min(1).max(128),
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
purpose: z.string().max(64).optional(),
});
const CompleteSchema = z.object({
action: z.literal("complete"),
key: z.string().min(1).max(512),
uploadId: z.string().min(1).max(512).optional(),
filename: z.string().min(1).max(256),
contentType: z.string().min(1).max(128),
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
parts: z
.array(
z.object({
ETag: z.string().min(1),
PartNumber: z.number().int().positive(),
})
)
.optional(),
purpose: z.string().max(64).optional(),
});
const AbortSchema = z.object({
action: z.literal("abort"),
key: z.string().min(1).max(512),
uploadId: z.string().min(1).max(512),
});
const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema,
CompleteSchema,
AbortSchema,
]);
function normalizeContentType(contentType: string, filename: string) {
const trimmed = contentType.trim().toLowerCase();
return trimmed || getContentType(filename);
}
function sanitizeDisplayName(filename: string) {
return (
filename
.trim()
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || `upload-${Date.now()}`
);
}
function userUploadPrefix(userId: string) {
return `${DIRECT_UPLOAD_PREFIX}/${userId}`;
}
function assertUserOwnedKey(key: string, userId: string) {
const normalized = String(key || "").replace(/^\/+/, "");
const prefix = `${userUploadPrefix(userId)}/`;
if (!normalized.startsWith(prefix)) {
throw new Error("Invalid upload object key");
}
return normalized;
}
function isAllowedDirectUpload(contentType: string) {
return allowedDirectUploadTypes.has(contentType);
}
function computePartSize(size: number) {
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
return 16 * 1024 * 1024;
}
async function createPartUrls(params: {
client: ReturnType<typeof createS3Client>;
bucket: string;
key: string;
uploadId: string;
partCount: number;
}) {
const urls = [];
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
const command = new UploadPartCommand({
Bucket: params.bucket,
Key: params.key,
UploadId: params.uploadId,
PartNumber: partNumber,
});
urls.push({
partNumber,
url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}),
});
}
return urls;
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let payload: z.infer<typeof RequestSchema>;
try {
payload = RequestSchema.parse(await request.json());
} catch (error) {
return NextResponse.json(
{
error: "Invalid direct upload request",
details: error instanceof Error ? error.message : String(error),
},
{ status: 400 }
);
}
const target = await resolveS3UploadTarget().catch((error) => {
console.error("[DirectUpload] Failed to resolve storage target:", error);
return null;
});
if (!target) {
return NextResponse.json(
{ error: "Direct upload is not available for current storage backend" },
{ status: 409 }
);
}
const client = createS3Client(target.config);
const bucket = target.config.bucket;
try {
if (payload.action === "initiate") {
const contentType = normalizeContentType(
payload.contentType,
payload.filename
);
if (!isAllowedDirectUpload(contentType)) {
return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" },
{ status: 400 }
);
}
const displayName = sanitizeDisplayName(payload.filename);
const key = buildStorageObjectKey(
displayName,
userUploadPrefix(session.user.id)
);
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
ACL: "public-read",
});
const url = await getSignedUrl(client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
});
return NextResponse.json({
mode: "single",
key,
url,
headers: {
"Content-Type": contentType,
"x-amz-acl": "public-read",
},
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
});
}
const createResult = await client.send(
new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
ACL: "public-read",
})
);
const uploadId = createResult.UploadId;
if (!uploadId) {
throw new Error("Storage provider did not return uploadId");
}
const partSize = computePartSize(payload.size);
const partCount = Math.ceil(payload.size / partSize);
const parts = await createPartUrls({
client,
bucket,
key,
uploadId,
partCount,
});
return NextResponse.json({
mode: "multipart",
key,
uploadId,
partSize,
partCount,
parts,
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
});
}
if (payload.action === "abort") {
const key = assertUserOwnedKey(payload.key, session.user.id);
await client.send(
new AbortMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId: payload.uploadId,
})
);
return NextResponse.json({ ok: true });
}
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType)) {
return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" },
{ status: 400 }
);
}
if (payload.uploadId) {
const parts = [...(payload.parts || [])].sort(
(a, b) => a.PartNumber - b.PartNumber
);
if (parts.length === 0) {
return NextResponse.json(
{ error: "Missing multipart upload parts" },
{ status: 400 }
);
}
await client.send(
new CompleteMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId: payload.uploadId,
MultipartUpload: {
Parts: parts,
},
})
);
}
const head = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key,
})
);
const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null;
if (actualSize !== null && actualSize <= 0) {
throw new Error("Uploaded object is empty");
}
const url = buildS3PublicUrl(target.config, key);
const resolvedContentType =
head.ContentType || contentType || getContentType(payload.filename);
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "upload",
status: "ready",
originalUrl: url,
previewUrl: url,
thumbnailUrl: url,
mimeType: resolvedContentType,
displayName: payload.filename,
sourceTable: "direct_upload",
metadata: {
pathname: key,
size: actualSize ?? payload.size,
expectedSize: payload.size,
uploadMode: payload.uploadId ? "multipart" : "single",
purpose: payload.purpose || null,
},
});
await maybeEnqueueStorageMigrationTask({
uploaded: { url, pathname: key },
storageConfig: target.storageConfig,
migrationConfig: target.migrationConfig,
}).catch((error) => {
console.error("[DirectUpload] Failed to enqueue OSS migration task:", error);
});
return NextResponse.json({
url,
pathname: key,
contentType: resolvedContentType,
assetId: asset?.id ?? null,
});
} catch (error) {
console.error("[DirectUpload] Request failed:", error);
return NextResponse.json(
{
error: "Direct upload failed",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}