feat: heic support
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
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 { deleteUploadObject, isObjectKeyForInvitation } from "@/lib/storage/uploads"
|
||||
import { completeUploadSchema } from "@/lib/validation/upload"
|
||||
import {
|
||||
createInvitationUploadKey,
|
||||
createPresignedDownloadUrl,
|
||||
deleteUploadObject,
|
||||
downloadUploadObject,
|
||||
getUploadedObjectMetadata,
|
||||
isObjectKeyForInvitation,
|
||||
putUploadObject,
|
||||
} from "@/lib/storage/uploads"
|
||||
import { completeUploadSchema, maxUploadSizeBytes } from "@/lib/validation/upload"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
@@ -14,7 +23,13 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "Upload deadline has passed" }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = completeUploadSchema.safeParse(await request.json())
|
||||
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 })
|
||||
@@ -30,19 +45,62 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "Invalid object key" }, { status: 400 })
|
||||
}
|
||||
|
||||
let metadata: Awaited<ReturnType<typeof getUploadedObjectMetadata>>
|
||||
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: parsed.data.objectKey,
|
||||
originalFilename: parsed.data.filename,
|
||||
contentType: parsed.data.contentType,
|
||||
sizeBytes: parsed.data.sizeBytes,
|
||||
objectKey,
|
||||
originalFilename: filename,
|
||||
contentType,
|
||||
sizeBytes,
|
||||
})
|
||||
|
||||
if (previousUpload && previousUpload.object_key !== parsed.data.objectKey) {
|
||||
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 })
|
||||
return NextResponse.json({
|
||||
upload,
|
||||
photo: { filename: upload.original_filename, url: await createPresignedDownloadUrl(upload.object_key) },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { getInvitationByToken } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId } from "@/lib/db/queries/uploads"
|
||||
import { createPresignedDownloadUrl } from "@/lib/storage/uploads"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token")
|
||||
if (!token) return NextResponse.json({ message: "Invitation token is required" }, { status: 400 })
|
||||
|
||||
const invitation = await getInvitationByToken(token)
|
||||
if (!invitation) return NextResponse.json({ message: "Invitation not found" }, { status: 404 })
|
||||
|
||||
const upload = await getPhotoUploadByInvitationId(invitation.invitation.id)
|
||||
if (!upload) return NextResponse.json({ photo: null }, { headers: { "cache-control": "private, no-store" } })
|
||||
|
||||
return NextResponse.json(
|
||||
{ photo: { filename: upload.original_filename, url: await createPresignedDownloadUrl(upload.object_key) } },
|
||||
{ headers: { "cache-control": "private, no-store" } },
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,13 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "Upload deadline has passed" }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = presignUploadSchema.safeParse(await request.json())
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Invalid upload request" }, { status: 400 })
|
||||
}
|
||||
const parsed = presignUploadSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: "Invalid upload request" }, { status: 400 })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getGuestDictionary } from "@/lib/i18n/get-dictionary"
|
||||
import { isSupportedLanguage } from "@/lib/i18n/config"
|
||||
import { getInvitePageContent } from "@/lib/i18n/invite-page/content"
|
||||
import { isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import { createPresignedDownloadUrl } from "@/lib/storage/uploads"
|
||||
|
||||
import { submitRsvpAction } from "./actions"
|
||||
|
||||
@@ -31,6 +32,9 @@ export default async function InvitePage({
|
||||
getInvitationInteractions(detail.invitation.id),
|
||||
getPhotoUploadByInvitationId(detail.invitation.id),
|
||||
])
|
||||
const photo = photoUpload
|
||||
? { filename: photoUpload.original_filename, url: await createPresignedDownloadUrl(photoUpload.object_key) }
|
||||
: null
|
||||
|
||||
return <WeddingInvite
|
||||
action={submitRsvpAction.bind(null, token)}
|
||||
@@ -40,7 +44,7 @@ export default async function InvitePage({
|
||||
content={getInvitePageContent(language)}
|
||||
closed={isRsvpClosed()}
|
||||
toastRequest={interactions.toastRequests[0]}
|
||||
filename={photoUpload?.original_filename ?? null}
|
||||
photo={photo}
|
||||
submitted={submitted}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user