import { NextResponse } from "next/server" import convert from "heic-convert" import { getInvitationByToken } from "@/lib/db/queries/invitations" import { getPhotoUploadByInvitationId, upsertPhotoUpload } from "@/lib/db/queries/uploads" import { isRsvpClosed } from "@/lib/rsvp-deadline" import { getUploadBucket } from "@/lib/storage/s3" import { createInvitationUploadKey, createPresignedDownloadUrl, deleteUploadObject, downloadUploadObject, getUploadedObjectMetadata, isObjectKeyForInvitation, putUploadObject, } from "@/lib/storage/uploads" import { completeUploadSchema, maxUploadSizeBytes } from "@/lib/validation/upload" export const runtime = "nodejs" export async function POST(request: Request) { if (isRsvpClosed()) { return NextResponse.json({ message: "Upload deadline has passed" }, { status: 403 }) } let body: unknown try { body = await request.json() } catch { return NextResponse.json({ message: "Invalid upload completion" }, { status: 400 }) } const parsed = completeUploadSchema.safeParse(body) if (!parsed.success) { return NextResponse.json({ message: "Invalid upload completion" }, { status: 400 }) } const invitation = await getInvitationByToken(parsed.data.token) if (!invitation) { return NextResponse.json({ message: "Invitation not found" }, { status: 404 }) } if (!isObjectKeyForInvitation(parsed.data.objectKey, invitation.invitation.id)) { return NextResponse.json({ message: "Invalid object key" }, { status: 400 }) } let metadata: Awaited> try { metadata = await getUploadedObjectMetadata(parsed.data.objectKey) } catch { return NextResponse.json({ message: "Uploaded object not found" }, { status: 400 }) } if (metadata.contentType !== parsed.data.contentType || metadata.sizeBytes !== parsed.data.sizeBytes) { return NextResponse.json({ message: "Uploaded object does not match" }, { status: 400 }) } const isHeic = parsed.data.contentType === "image/heic" || parsed.data.contentType === "image/heif" let objectKey = parsed.data.objectKey let filename = parsed.data.filename let contentType = parsed.data.contentType let sizeBytes = parsed.data.sizeBytes if (isHeic) { try { const input = await downloadUploadObject(parsed.data.objectKey, maxUploadSizeBytes) const jpeg = await convert({ buffer: input, format: "JPEG", quality: 0.92 }) if (jpeg.length === 0 || jpeg.length > maxUploadSizeBytes) { return NextResponse.json({ message: "Converted image has an invalid size" }, { status: 400 }) } filename = `${parsed.data.filename.replace(/\.[^.]*$/, "") || "upload"}.jpg` objectKey = createInvitationUploadKey(invitation.invitation.id, filename) await putUploadObject({ objectKey, contentType: "image/jpeg", body: jpeg }) const finalMetadata = await getUploadedObjectMetadata(objectKey) if (finalMetadata.contentType !== "image/jpeg" || finalMetadata.sizeBytes !== jpeg.length) { throw new Error("Converted object does not match") } contentType = "image/jpeg" sizeBytes = jpeg.length } catch { return NextResponse.json({ message: "Unable to convert uploaded image" }, { status: 400 }) } } const previousUpload = await getPhotoUploadByInvitationId(invitation.invitation.id) const upload = await upsertPhotoUpload({ invitationId: invitation.invitation.id, bucket: getUploadBucket(), objectKey, originalFilename: filename, contentType, sizeBytes, }) if (isHeic) { await deleteUploadObject(parsed.data.objectKey).catch(() => undefined) } if (previousUpload && previousUpload.object_key !== objectKey) { await deleteUploadObject(previousUpload.object_key).catch(() => undefined) } return NextResponse.json({ upload, photo: { filename: upload.original_filename, url: await createPresignedDownloadUrl(upload.object_key) }, }) }