diff --git a/package-lock.json b/package-lock.json index d79bdb5..acea0ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "babel-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "crypto-js": "^4.2.0", "framer-motion": "^12.0.0-alpha.1", "grammy": "^1.32.0", "howler": "^2.2.4", @@ -68,6 +69,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", + "@types/crypto-js": "^4.2.2", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", "@types/node": "^20", @@ -4849,6 +4851,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", diff --git a/package.json b/package.json index d3749b4..8461c37 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "babel-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "crypto-js": "^4.2.0", "framer-motion": "^12.0.0-alpha.1", "grammy": "^1.32.0", "howler": "^2.2.4", @@ -80,6 +81,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", + "@types/crypto-js": "^4.2.2", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", "@types/node": "^20", diff --git a/src/actions.ts b/src/actions.ts index f1dd4f1..ea5e84c 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -240,35 +240,53 @@ export async function persistSamplePackDataAction( } } -export async function createPreSignedUrlAction(numOfSamples: number) { +const fileDataSchema = z.object({ + hash: z.string(), + fileType: z.string(), +}); + +const createPresignedUrlActionSchema = z.object({ + img: fileDataSchema, + zip: fileDataSchema, + samples: z.array(fileDataSchema), +}); + +type CreatePreSignedUrlActionSchema = z.infer< + typeof createPresignedUrlActionSchema +>; + +export async function createPreSignedUrlAction({ + img, + zip, + samples, +}: CreatePreSignedUrlActionSchema) { try { const user = await currentUser(); if (!user) throw new Error(); - - if (!numOfSamples || typeof numOfSamples !== "number") { - throw new TypeError( - `Invalid number of samples. Expected a number, got: ${numOfSamples}` - ); - } + createPresignedUrlActionSchema.parse({ img, zip, samples }); const zipFileSignedUrlPromise = createPresignedUrl({ bucketName: AWS_PRIVATE_BUCKET_NAME, - fileType: "zip", + type: "zip", + hash: zip.hash, + fileType: zip.fileType, }); const imageSignedUrlPromise = createPresignedUrl({ bucketName: AWS_PUBLIC_BUCKET_NAME, - fileType: "image", + type: "image", + hash: img.hash, + fileType: img.fileType, }); - const samplesSignedUrlsPromises = new Array(numOfSamples) - .fill(null) - .map(() => - createPresignedUrl({ - bucketName: AWS_PUBLIC_BUCKET_NAME, - fileType: "samples", - }) - ); + const samplesSignedUrlsPromises = samples.map(({ hash, fileType }) => + createPresignedUrl({ + bucketName: AWS_PUBLIC_BUCKET_NAME, + type: "samples", + hash, + fileType, + }) + ); const [zipFileSignedUrl, imageSignedUrl, ...samplesSignedUrls] = await Promise.all([ diff --git a/src/components/upload/info-and-controls.tsx b/src/app/upload/_components/info-and-controls.tsx similarity index 97% rename from src/components/upload/info-and-controls.tsx rename to src/app/upload/_components/info-and-controls.tsx index 8e38532..5f8634a 100644 --- a/src/components/upload/info-and-controls.tsx +++ b/src/app/upload/_components/info-and-controls.tsx @@ -2,7 +2,7 @@ import NumberFlow from "@number-flow/react"; import { cn } from "@/utils"; import { Button } from "@/components/ui/button"; import { UseFormReturn } from "react-hook-form"; -import { UploadFormSchema } from "@/components/upload-form-schema"; +import { UploadFormSchema } from "@/app/upload/upload-form-schema"; import { ArrowLeft, Loader, ArrowRight } from "lucide-react"; import type { Dispatch, SetStateAction } from "react"; diff --git a/src/components/upload/multi-step.tsx b/src/app/upload/_components/multi-step.tsx similarity index 96% rename from src/components/upload/multi-step.tsx rename to src/app/upload/_components/multi-step.tsx index 942d6b4..3194769 100644 --- a/src/components/upload/multi-step.tsx +++ b/src/app/upload/_components/multi-step.tsx @@ -1,11 +1,10 @@ "use client"; import useMeasure from "react-use-measure"; -import StepInfoAndControls from "@/components/upload/info-and-controls"; import { Form } from "@/components/ui/form"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useUploadPack } from "@/hooks/upload-pack"; +import { useUploadPack } from "@/app/upload/use-upload-pack"; import { container, item } from "@/anim"; import { useMemo, useState } from "react"; import { AnimatePresence, motion, MotionConfig } from "motion/react"; @@ -13,7 +12,9 @@ import { AnimatePresence, motion, MotionConfig } from "motion/react"; import { uploadFormSchema, type UploadFormSchema, -} from "@/components/upload-form-schema"; +} from "@/app/upload/upload-form-schema"; + +import StepInfoAndControls from "@/app/upload/_components/info-and-controls"; import { StepFive, @@ -22,7 +23,7 @@ import { StepSix, StepThree, StepTwo, -} from "@/components/upload/steps/mod"; +} from "@/app/upload/_components/steps/mod"; const VARIANTS = { initial: (direction: number) => ({ x: `${110 * direction}%`, opacity: 0 }), diff --git a/src/components/upload/steps/five.tsx b/src/app/upload/_components/steps/five.tsx similarity index 97% rename from src/components/upload/steps/five.tsx rename to src/app/upload/_components/steps/five.tsx index 583408f..734c814 100644 --- a/src/components/upload/steps/five.tsx +++ b/src/app/upload/_components/steps/five.tsx @@ -1,7 +1,7 @@ import Dropzone from "react-dropzone"; import { Input } from "@/components/ui/input"; import { UseFormReturn } from "react-hook-form"; -import { UploadFormSchema } from "@/components/upload-form-schema"; +import { UploadFormSchema } from "@/app/upload/upload-form-schema"; import { FormField, diff --git a/src/components/upload/steps/four.tsx b/src/app/upload/_components/steps/four.tsx similarity index 98% rename from src/components/upload/steps/four.tsx rename to src/app/upload/_components/steps/four.tsx index 084bb8f..e1101d6 100644 --- a/src/components/upload/steps/four.tsx +++ b/src/app/upload/_components/steps/four.tsx @@ -9,7 +9,7 @@ import { FormMessage, } from "@/components/ui/form"; -import type { UploadFormSchema } from "@/components/upload-form-schema"; +import type { UploadFormSchema } from "@/app/upload/upload-form-schema"; import type { UseFormReturn } from "react-hook-form"; import { Textarea } from "@/components/ui/textarea"; diff --git a/src/components/upload/steps/mod.tsx b/src/app/upload/_components/steps/mod.tsx similarity index 100% rename from src/components/upload/steps/mod.tsx rename to src/app/upload/_components/steps/mod.tsx diff --git a/src/components/upload/steps/one.tsx b/src/app/upload/_components/steps/one.tsx similarity index 97% rename from src/components/upload/steps/one.tsx rename to src/app/upload/_components/steps/one.tsx index 79d9af8..eb6d7e0 100644 --- a/src/components/upload/steps/one.tsx +++ b/src/app/upload/_components/steps/one.tsx @@ -2,7 +2,7 @@ import Dropzone from "react-dropzone"; import { FormField, FormItem, FormControl } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import type { UploadFormSchema } from "@/components/upload-form-schema"; +import type { UploadFormSchema } from "@/app/upload/upload-form-schema"; import type { Dispatch, SetStateAction } from "react"; import type { UseFormReturn } from "react-hook-form"; diff --git a/src/components/upload/steps/six.tsx b/src/app/upload/_components/steps/six.tsx similarity index 98% rename from src/components/upload/steps/six.tsx rename to src/app/upload/_components/steps/six.tsx index 1e7f858..e424974 100644 --- a/src/components/upload/steps/six.tsx +++ b/src/app/upload/_components/steps/six.tsx @@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Loader, Check } from "lucide-react"; import type { UseFormReturn } from "react-hook-form"; -import type { UploadFormSchema } from "@/components/upload-form-schema"; +import type { UploadFormSchema } from "@/app/upload/upload-form-schema"; export default function StepSix({ form, diff --git a/src/components/upload/steps/three.tsx b/src/app/upload/_components/steps/three.tsx similarity index 97% rename from src/components/upload/steps/three.tsx rename to src/app/upload/_components/steps/three.tsx index 21a8244..be38a1d 100644 --- a/src/components/upload/steps/three.tsx +++ b/src/app/upload/_components/steps/three.tsx @@ -1,6 +1,6 @@ import Dropzone from "react-dropzone"; import { Input } from "@/components/ui/input"; -import { UploadFormSchema } from "@/components/upload-form-schema"; +import { UploadFormSchema } from "@/app/upload/upload-form-schema"; import { FormField, diff --git a/src/components/upload/steps/two.tsx b/src/app/upload/_components/steps/two.tsx similarity index 98% rename from src/components/upload/steps/two.tsx rename to src/app/upload/_components/steps/two.tsx index f2684fb..d58d90e 100644 --- a/src/components/upload/steps/two.tsx +++ b/src/app/upload/_components/steps/two.tsx @@ -16,7 +16,7 @@ import { AnimatePresence, } from "framer-motion"; -import type { UploadFormSchema } from "@/components/upload-form-schema"; +import type { UploadFormSchema } from "@/app/upload/upload-form-schema"; export default function StepTwo({ form, diff --git a/src/app/upload/md5.ts b/src/app/upload/md5.ts new file mode 100644 index 0000000..9a097c8 --- /dev/null +++ b/src/app/upload/md5.ts @@ -0,0 +1,25 @@ +import CryptoJS from "crypto-js"; + +export async function md5(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + try { + const buffer = event.target?.result; + if (!(buffer instanceof ArrayBuffer)) { + throw new Error("Failed to read file as ArrayBuffer."); + } + + const wordArray = CryptoJS.lib.WordArray.create(new Uint8Array(buffer)); + const md5Hash = CryptoJS.MD5(wordArray); + const base64Hash = md5Hash.toString(CryptoJS.enc.Base64); + resolve(base64Hash); + } catch (error) { + console.error("Error generating MD5 hash:", error); + reject(error); + } + }; + reader.onerror = (error) => reject(error); + reader.readAsArrayBuffer(file); + }); +} diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 6c6f6f5..a9e08eb 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,4 +1,4 @@ -import UploadPage from "@/components/upload/multi-step"; +import UploadPage from "@/app/upload/_components/multi-step"; import { STORAGE_LIMIT_50_GB_IN_BYTES } from "@/consts"; import { doesUserHaveStripeAccount, getUserUsedStorage } from "@/db/mod"; import { hasRequirementsDue } from "@/stripe"; diff --git a/src/components/upload-form-schema.ts b/src/app/upload/upload-form-schema.ts similarity index 100% rename from src/components/upload-form-schema.ts rename to src/app/upload/upload-form-schema.ts diff --git a/src/aws/upload.ts b/src/app/upload/upload.ts similarity index 91% rename from src/aws/upload.ts rename to src/app/upload/upload.ts index 47c29fd..d7f690c 100644 --- a/src/aws/upload.ts +++ b/src/app/upload/upload.ts @@ -1,5 +1,7 @@ "use client"; +import { md5 } from "@/app/upload/md5"; + export type UploadToS3Data = { zipFileSignedUrl: string; zipFile: File; @@ -37,11 +39,13 @@ export async function handleUploadToS3({ } async function uploadFile(url: string, file: File) { + const hash = await md5(file); const response = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type, + "Content-MD5": hash, }, }); diff --git a/src/hooks/upload-pack.tsx b/src/app/upload/use-upload-pack.tsx similarity index 82% rename from src/hooks/upload-pack.tsx rename to src/app/upload/use-upload-pack.tsx index d24aebe..c8303db 100644 --- a/src/hooks/upload-pack.tsx +++ b/src/app/upload/use-upload-pack.tsx @@ -1,6 +1,6 @@ "use client"; -import { UploadToS3Data, handleUploadToS3 } from "@/aws/upload"; +import { UploadToS3Data, handleUploadToS3 } from "@/app/upload/upload"; import { createSamplePackName, getAudioDuration, isDev } from "@/utils"; import { useMutation } from "@tanstack/react-query"; import { @@ -9,7 +9,8 @@ import { updateUserUsedStorageAction, } from "@/actions"; -import type { UploadFormSchema } from "@/components/upload-form-schema"; +import type { UploadFormSchema } from "@/app/upload/upload-form-schema"; +import { md5 } from "@/app/upload/md5"; type PreSignedUrls = Awaited>; @@ -23,8 +24,24 @@ export function useUploadPack({ data: preSignedUrls, isPending: isCreatingPresignedUrls, } = useMutation({ - mutationFn: async () => - await createPreSignedUrlAction(formValues.samples?.length), + mutationFn: async () => { + const [imageHash, zipHash, ...samplesHashes] = await Promise.all([ + md5(formValues.zipFile), + md5(formValues.img), + ...formValues.samples.map((sample) => md5(sample.file)), + ]); + + const data = { + img: { hash: imageHash, fileType: formValues.img.type }, + zip: { hash: zipHash, fileType: formValues.zipFile.type }, + samples: samplesHashes.map((hash, index) => ({ + hash, + fileType: formValues.samples[index].file.type, + })), + }; + + return await createPreSignedUrlAction(data); + }, onSuccess: createSignedUrlsOnSuccess, }); diff --git a/src/aws/mod.ts b/src/aws/mod.ts index 2555783..de24305 100644 --- a/src/aws/mod.ts +++ b/src/aws/mod.ts @@ -52,16 +52,22 @@ export async function listBuckets() { export async function createPresignedUrl({ bucketName, + type, + hash, fileType, }: { bucketName: string; - fileType: "image" | "samples" | "zip"; + type: "image" | "samples" | "zip"; + hash: string; + fileType: string; }) { const randomPrefix = crypto.randomBytes(16).toString("hex"); - const key = `uploads/${fileType}/${randomPrefix}`; + const key = `uploads/${type}/${randomPrefix}`; const command = new PutObjectCommand({ Bucket: bucketName, Key: key, + ContentMD5: hash, + ContentType: fileType, }); const url = await getSignedUrl(s3, command, {