diff --git a/app/api/uploads/complete/route.ts b/app/api/uploads/complete/route.ts index 91039ad..2b9d121 100644 --- a/app/api/uploads/complete/route.ts +++ b/app/api/uploads/complete/route.ts @@ -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> + 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) }, + }) } diff --git a/app/api/uploads/current/route.ts b/app/api/uploads/current/route.ts new file mode 100644 index 0000000..1832426 --- /dev/null +++ b/app/api/uploads/current/route.ts @@ -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" } }, + ) +} diff --git a/app/api/uploads/presign/route.ts b/app/api/uploads/presign/route.ts index 54fe618..49fcf18 100644 --- a/app/api/uploads/presign/route.ts +++ b/app/api/uploads/presign/route.ts @@ -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 }) diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index fdcd5ac..333581c 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -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 diff --git a/components/invite/photo-preview.tsx b/components/invite/photo-preview.tsx new file mode 100644 index 0000000..499541d --- /dev/null +++ b/components/invite/photo-preview.tsx @@ -0,0 +1,11 @@ +"use client" + +import { useState } from "react" + +export function PhotoPreview({ photo, alt }: { photo: { url: string; fallback?: string }; alt: string }) { + const [failed, setFailed] = useState(false) + + // Signed storage URLs must bypass Next's image optimizer and remote-host allowlist. + // eslint-disable-next-line @next/next/no-img-element + return
{alt} setFailed(true)} className={`size-full object-contain ${failed ? "hidden" : ""}`} referrerPolicy="no-referrer" />{failed ?

{photo.fallback ?? alt}

: null}
+} diff --git a/components/invite/photo-upload.tsx b/components/invite/photo-upload.tsx index f63630e..34677c6 100644 --- a/components/invite/photo-upload.tsx +++ b/components/invite/photo-upload.tsx @@ -1,155 +1,76 @@ "use client" -import { useRef, useState } from "react" +import { useEffect, useRef, useState } from "react" -import { allowedImageContentTypes, maxUploadSizeBytes } from "@/lib/validation/upload" +import { PhotoPreview } from "@/components/invite/photo-preview" +import { allowedImageContentTypes, allowedImageExtensions, getUploadContentType, maxUploadSizeBytes } from "@/lib/validation/upload" interface PhotoUploadLabels { - title: string - description: string - current: string - choose: string - upload: string - replace: string - uploading: string - success: string - error: string - disabled: string - requirements: string + title: string; description: string; current: string; choose: string; upload: string + replace: string; uploading: string; success: string; error: string; disabled: string + requirements: string; saved: string; selected: string; invalid: string; refresh: string + previewUnavailable: string } +type Photo = { filename: string | null; url: string } -export function PhotoUpload({ - token, - labels, - disabled, - existingFilename, -}: { - token: string - labels: PhotoUploadLabels - disabled: boolean - existingFilename: string | null +export function PhotoUpload({ token, labels, disabled, existingPhoto }: { + token: string; labels: PhotoUploadLabels; disabled: boolean; existingPhoto: Photo | null }) { const inputRef = useRef(null) - const [filename, setFilename] = useState(existingFilename) + const [savedPhoto, setSavedPhoto] = useState(existingPhoto) + const [selected, setSelected] = useState<{ file: File; url: string } | null>(null) const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle") - async function uploadSelectedFile() { - const file = inputRef.current?.files?.[0] - if (!file) return + useEffect(() => () => { if (selected) URL.revokeObjectURL(selected.url) }, [selected]) - if ( - !allowedImageContentTypes.includes( - file.type as (typeof allowedImageContentTypes)[number], - ) || - file.size > maxUploadSizeBytes - ) { + function chooseFile(file: File | undefined) { + if (!file) return + if (!getUploadContentType(file) || file.size > maxUploadSizeBytes || file.size === 0) { + setSelected((previous) => { + if (previous) URL.revokeObjectURL(previous.url) + return null + }) setStatus("error") return } + setStatus("idle") + setSelected((previous) => { + if (previous) URL.revokeObjectURL(previous.url) + return { file, url: URL.createObjectURL(file) } + }) + } + async function uploadSelectedFile() { + if (!selected) return setStatus("uploading") - try { - const presignResponse = await fetch("/api/uploads/presign", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - token, - filename: file.name, - contentType: file.type, - sizeBytes: file.size, - }), - }) - - if (!presignResponse.ok) throw new Error("Could not presign upload") - - const { uploadUrl, objectKey } = (await presignResponse.json()) as { - uploadUrl: string - objectKey: string - } - - const uploadResponse = await fetch(uploadUrl, { - method: "PUT", - headers: { "content-type": file.type }, - body: file, - }) - - if (!uploadResponse.ok) throw new Error("Could not upload file") - - const completeResponse = await fetch("/api/uploads/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - token, - filename: file.name, - contentType: file.type, - sizeBytes: file.size, - objectKey, - }), - }) - - if (!completeResponse.ok) throw new Error("Could not complete upload") - - setFilename(file.name) - setStatus("success") + const contentType = getUploadContentType(selected.file) + if (!contentType) throw new Error("invalid-file") + const input = { token, filename: selected.file.name, contentType, sizeBytes: selected.file.size } + const presign = await fetch("/api/uploads/presign", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }) + if (!presign.ok) throw new Error("presign") + const { uploadUrl, objectKey } = await presign.json() as { uploadUrl: string; objectKey: string } + const upload = await fetch(uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: selected.file }) + if (!upload.ok) throw new Error("upload") + const complete = await fetch("/api/uploads/complete", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...input, objectKey }) }) + if (!complete.ok) throw new Error("complete") + const result = await complete.json() as { photo: Photo } + setSavedPhoto(result.photo) + setSelected(null) if (inputRef.current) inputRef.current.value = "" - } catch { - setStatus("error") - } + setStatus("success") + } catch { setStatus("error") } } return (
-
-

04

-
-

{labels.title}

-

{labels.description}

-
-

{labels.requirements}

-
- -
- {filename ? ( -

- {labels.current.replace("{filename}", filename)} -

- ) : null} - - {disabled ? ( -

- {labels.disabled} -

- ) : ( -
- - -
- )} - - {status === "success" ? ( -

{labels.success}

- ) : null} - {status === "error" ?

{labels.error}

: null} +

04

{labels.title}

{labels.description}

{labels.requirements}

+
+ {savedPhoto ?

{labels.saved}

: null} + {selected ?

{labels.selected}

: null} + {disabled ?

{labels.disabled}

:
} + {status === "success" ?

{labels.success}

: null} + {status === "error" ?

{selected ? labels.error : labels.invalid}

: null}
) diff --git a/components/invite/rsvp-form.tsx b/components/invite/rsvp-form.tsx index efdb622..4a09d14 100644 --- a/components/invite/rsvp-form.tsx +++ b/components/invite/rsvp-form.tsx @@ -14,7 +14,7 @@ export function RsvpForm({ dictionary, closed, toastRequest, - filename, + photo, }: { action: (formData: FormData) => void | Promise detail: InvitationDetail @@ -22,14 +22,14 @@ export function RsvpForm({ dictionary: GuestDictionary closed: boolean toastRequest: ToastRequest | undefined - filename: string | null + photo: { filename: string | null; url: string } | null }) { return (
-
+
{!closed ?
: null} ) diff --git a/components/invite/rsvp-section.tsx b/components/invite/rsvp-section.tsx index ff56c3d..f7a6a10 100644 --- a/components/invite/rsvp-section.tsx +++ b/components/invite/rsvp-section.tsx @@ -15,7 +15,7 @@ export function RsvpSection(props: { content: InvitePageContent["rsvp"] closed: boolean toastRequest: ToastRequest | undefined - filename: string | null + photo: { filename: string | null; url: string } | null submitted?: string error?: string }) { diff --git a/components/invite/wedding-invite.tsx b/components/invite/wedding-invite.tsx index ebbc16f..01762f5 100644 --- a/components/invite/wedding-invite.tsx +++ b/components/invite/wedding-invite.tsx @@ -18,7 +18,7 @@ export function WeddingInvite({ content, closed, toastRequest, - filename, + photo, submitted, error, }: { @@ -29,7 +29,7 @@ export function WeddingInvite({ content: InvitePageContent closed: boolean toastRequest: ToastRequest | undefined - filename: string | null + photo: { filename: string | null; url: string } | null submitted?: string error?: string }) { @@ -41,7 +41,7 @@ export function WeddingInvite({ - + ) } diff --git a/lib/i18n/dictionaries/de.ts b/lib/i18n/dictionaries/de.ts index 5caaea4..2a0c60c 100644 --- a/lib/i18n/dictionaries/de.ts +++ b/lib/i18n/dictionaries/de.ts @@ -75,6 +75,11 @@ export const de = { success: "Foto erfolgreich hochgeladen.", error: "Das Foto konnte nicht hochgeladen werden. Bitte versuche es erneut.", disabled: "Foto-Uploads sind geschlossen, da die RSVP-Frist abgelaufen ist.", - requirements: "JPG, PNG oder WebP. Maximal 10 MB.", + requirements: "JPG, JPEG, PNG, WebP, HEIC oder HEIF. Maximal 10 MB.", + saved: "Aktuell hochgeladenes Foto", + selected: "Ausgewähltes Foto — noch nicht hochgeladen", + invalid: "Bitte wähle ein JPG-, JPEG-, PNG-, WebP-, HEIC- oder HEIF-Bild bis 10 MB.", + refresh: "Das gespeicherte Foto konnte nicht geladen werden. Bitte aktualisiere die Seite.", + previewUnavailable: "Dieses Format kann hier nicht angezeigt werden, wird aber nach dem Hochladen in JPEG umgewandelt.", }, } as const diff --git a/lib/i18n/dictionaries/en.ts b/lib/i18n/dictionaries/en.ts index f643a8b..c689bd0 100644 --- a/lib/i18n/dictionaries/en.ts +++ b/lib/i18n/dictionaries/en.ts @@ -75,6 +75,11 @@ export const en = { success: "Photo uploaded successfully.", error: "We could not upload the photo. Please try again.", disabled: "Photo uploads are closed because the RSVP deadline has passed.", - requirements: "JPG, PNG, or WebP. Maximum 10 MB.", + requirements: "JPG, JPEG, PNG, WebP, HEIC, or HEIF. Maximum 10 MB.", + saved: "Currently uploaded photo", + selected: "Selected photo — not uploaded yet", + invalid: "Please choose a JPG, JPEG, PNG, WebP, HEIC, or HEIF image up to 10 MB.", + refresh: "The saved photo could not be loaded. Try refreshing the page.", + previewUnavailable: "This format can't be previewed here, but it will be converted to a JPEG once uploaded.", }, } as const diff --git a/lib/i18n/dictionaries/es.ts b/lib/i18n/dictionaries/es.ts index 89f6cb7..563b699 100644 --- a/lib/i18n/dictionaries/es.ts +++ b/lib/i18n/dictionaries/es.ts @@ -75,6 +75,11 @@ export const es = { success: "Foto subida correctamente.", error: "No pudimos subir la foto. Inténtalo de nuevo.", disabled: "La subida de fotos está cerrada porque ya pasó la fecha límite.", - requirements: "JPG, PNG o WebP. Máximo 10 MB.", + requirements: "JPG, JPEG, PNG, WebP, HEIC o HEIF. Máximo 10 MB.", + saved: "Foto subida actualmente", + selected: "Foto seleccionada — todavía no subida", + invalid: "Elige una imagen JPG, JPEG, PNG, WebP, HEIC o HEIF de hasta 10 MB.", + refresh: "No se pudo cargar la foto guardada. Intenta actualizar la página.", + previewUnavailable: "Este formato no se puede previsualizar aquí, pero se convertirá a JPEG al subirlo.", }, } as const diff --git a/lib/i18n/dictionaries/pl.ts b/lib/i18n/dictionaries/pl.ts index 326d890..4faaadb 100644 --- a/lib/i18n/dictionaries/pl.ts +++ b/lib/i18n/dictionaries/pl.ts @@ -75,6 +75,11 @@ export const pl = { success: "Zdjęcie zostało przesłane.", error: "Nie udało się przesłać zdjęcia. Spróbuj ponownie.", disabled: "Przesyłanie zdjęć jest zamknięte, ponieważ termin odpowiedzi już minął.", - requirements: "JPG, PNG lub WebP. Maksymalnie 10 MB.", + requirements: "JPG, JPEG, PNG, WebP, HEIC lub HEIF. Maksymalnie 10 MB.", + saved: "Aktualnie przesłane zdjęcie", + selected: "Wybrane zdjęcie — jeszcze nieprzesłane", + invalid: "Wybierz zdjęcie JPG, JPEG, PNG, WebP, HEIC lub HEIF o rozmiarze do 10 MB.", + refresh: "Nie udało się załadować zapisanego zdjęcia. Odśwież stronę.", + previewUnavailable: "Tego formatu nie można tu wyświetlić, ale po przesłaniu zostanie przekonwertowany na JPEG.", }, } as const diff --git a/lib/storage/uploads.ts b/lib/storage/uploads.ts index ff714ea..09647f7 100644 --- a/lib/storage/uploads.ts +++ b/lib/storage/uploads.ts @@ -1,4 +1,4 @@ -import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3" +import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { nanoid } from "nanoid" @@ -61,6 +61,58 @@ export async function createPresignedDownloadUrl(objectKey: string) { ) } +export async function downloadUploadObject(objectKey: string, maxSizeBytes: number) { + const response = await getS3Client().send(new GetObjectCommand({ + Bucket: getUploadBucket(), + Key: objectKey, + })) + const body = response.Body + if (!body) throw new Error("Uploaded object has no body") + + if ("transformToByteArray" in body && typeof body.transformToByteArray === "function") { + const bytes = await body.transformToByteArray() + if (bytes.byteLength > maxSizeBytes) throw new Error("Uploaded object is too large") + return Buffer.from(bytes) + } + + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of body as AsyncIterable) { + const buffer = Buffer.from(chunk) + size += buffer.length + if (size > maxSizeBytes) throw new Error("Uploaded object is too large") + chunks.push(buffer) + } + return Buffer.concat(chunks, size) +} + +export async function putUploadObject({ + objectKey, + contentType, + body, +}: { + objectKey: string + contentType: string + body: Buffer +}) { + await getS3Client().send(new PutObjectCommand({ + Bucket: getUploadBucket(), + Key: objectKey, + Body: body, + ContentType: contentType, + ContentLength: body.length, + })) +} + +export async function getUploadedObjectMetadata(objectKey: string) { + const response = await getS3Client().send(new HeadObjectCommand({ + Bucket: getUploadBucket(), + Key: objectKey, + })) + + return { contentType: response.ContentType, sizeBytes: response.ContentLength } +} + export async function deleteUploadObject(objectKey: string) { const client = getS3Client() const bucket = getUploadBucket() diff --git a/lib/validation/upload.ts b/lib/validation/upload.ts index fd2ef8b..5703048 100644 --- a/lib/validation/upload.ts +++ b/lib/validation/upload.ts @@ -1,8 +1,33 @@ import { z } from "zod" -export const allowedImageContentTypes = ["image/jpeg", "image/png", "image/webp"] as const +export const allowedImageContentTypes = [ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", +] as const +export const allowedImageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"] as const export const maxUploadSizeBytes = 10 * 1024 * 1024 +export type AllowedImageContentType = (typeof allowedImageContentTypes)[number] + +export function getUploadContentType(file: File): AllowedImageContentType | null { + const type = file.type.toLowerCase() + if (type === "image/jpg") return "image/jpeg" + if (allowedImageContentTypes.includes(type as AllowedImageContentType)) { + return type as AllowedImageContentType + } + + const extension = `.${file.name.split(".").pop()?.toLowerCase() ?? ""}` + if ([".jpg", ".jpeg"].includes(extension)) return "image/jpeg" + if (extension === ".png") return "image/png" + if (extension === ".webp") return "image/webp" + if (extension === ".heic") return "image/heic" + if (extension === ".heif") return "image/heif" + return null +} + export const presignUploadSchema = z.object({ token: z.string().min(1), filename: z.string().trim().min(1).max(255), diff --git a/package-lock.json b/package-lock.json index f29f8f3..f80c27f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/s3-request-presigner": "^3.940.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "heic-convert": "^2.1.0", "kysely": "^0.28.8", "lucide-react": "^1.17.0", "nanoid": "^5.1.6", @@ -6460,6 +6461,32 @@ "node": ">= 0.4" } }, + "node_modules/heic-convert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz", + "integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==", + "license": "ISC", + "dependencies": { + "heic-decode": "^2.0.0", + "jpeg-js": "^0.4.4", + "pngjs": "^6.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/heic-decode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", + "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", + "license": "ISC", + "dependencies": { + "libheif-js": "^1.19.8" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -7031,6 +7058,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7252,6 +7285,15 @@ "node": ">= 0.8.0" } }, + "node_modules/libheif-js": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.2.tgz", + "integrity": "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw==", + "license": "LGPL-3.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -8271,6 +8313,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 70981cf..b6deb4a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@aws-sdk/s3-request-presigner": "^3.940.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "heic-convert": "^2.1.0", "kysely": "^0.28.8", "lucide-react": "^1.17.0", "nanoid": "^5.1.6", diff --git a/types/heic-convert.d.ts b/types/heic-convert.d.ts new file mode 100644 index 0000000..d3844b1 --- /dev/null +++ b/types/heic-convert.d.ts @@ -0,0 +1,11 @@ +declare module "heic-convert" { + type ConvertOptions = { + buffer: Buffer + format: "JPEG" | "PNG" + quality?: number + } + + function convert(options: ConvertOptions): Promise + + export default convert +}