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; 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; 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 } ); } }