355 lines
9.4 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|