diff --git a/app/(app)/create/[[...paramsArr]]/_client.tsx b/app/(app)/create/[[...paramsArr]]/_client.tsx index e98654be..1bd2a9fd 100644 --- a/app/(app)/create/[[...paramsArr]]/_client.tsx +++ b/app/(app)/create/[[...paramsArr]]/_client.tsx @@ -29,9 +29,9 @@ import copy from "copy-to-clipboard"; import { PostStatus, getPostStatus, isValidScheduleTime } from "@/utils/post"; import { ImageUp, LoaderCircle } from "lucide-react"; import { uploadFile } from "@/utils/s3helpers"; -import { type Session } from "next-auth"; +import { getUploadUrl } from "@/app/actions/getUploadUrl"; -const Create = ({ session }: { session: Session }) => { +const Create = () => { const params = useParams(); const router = useRouter(); @@ -71,29 +71,40 @@ const Create = ({ session }: { session: Session }) => { const file = e.target.files[0]; const { size, type } = file; - await getUploadUrl( - { size, type, config: { kind: "uploads", userId: session.user?.id } }, - { - onError(error) { - setUploadStatus("error"); - if (error) return toast.error(error.message); - return toast.error( - "Something went wrong uploading the photo, please retry.", - ); - }, - async onSuccess(signedUrl) { - const { fileLocation } = await uploadFile(signedUrl, file); - if (!fileLocation) { - setUploadStatus("error"); - return toast.error( - "Something went wrong uploading the photo, please retry.", - ); - } - setUploadStatus("success"); - setUploadUrl(fileLocation); - }, - }, - ); + try { + const res = await getUploadUrl({ + size, + type, + uploadType: "uploads", + }); + + const signedUrl = res?.data; + + if (!signedUrl) { + setUploadStatus("error"); + return toast.error( + "Something went wrong uploading the photo, please retry.", + ); + } + + const { fileLocation } = await uploadFile(signedUrl, file); + if (!fileLocation) { + setUploadStatus("error"); + return toast.error( + "Something went wrong uploading the photo, please retry.", + ); + } + setUploadStatus("success"); + setUploadUrl(fileLocation); + } catch (error) { + setUploadStatus("error"); + toast.error( + error instanceof Error + ? error.message + : "An error occurred while uploading the image.", + ); + Sentry.captureException(error); + } } }; @@ -162,8 +173,6 @@ const Create = ({ session }: { session: Session }) => { }, ); - const { mutate: getUploadUrl } = api.event.getUploadUrl.useMutation(); - const PREVIEW_URL = `${process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://www.codu.co"}/draft/${postId}`; const UPLOADED_IMAGE_URL = `![Image description](${uploadUrl})`; diff --git a/app/actions/getUploadUrl.ts b/app/actions/getUploadUrl.ts new file mode 100644 index 00000000..ffdbf5b0 --- /dev/null +++ b/app/actions/getUploadUrl.ts @@ -0,0 +1,44 @@ +"use server"; + +import * as Sentry from "@sentry/nextjs"; +import { getPresignedUrl } from "@/server/common/getPresignedUrl"; +import { authActionClient } from "@/server/lib/safeAction"; + +import { z } from "zod"; + +const schema = z.object({ + type: z.string(), + size: z.number(), + uploadType: z.enum(["uploads", "user"]).default("uploads"), +}); + +export const getUploadUrl = authActionClient + .schema(schema) + .action(async ({ parsedInput, ctx }) => { + const { type, size, uploadType } = parsedInput; + const extension = type.split("/")[1]; + const acceptedFormats = ["jpg", "jpeg", "gif", "png", "webp"]; + + if (!acceptedFormats.includes(extension)) { + throw new Error( + `Invalid file. Accepted file formats: ${acceptedFormats.join(", ")}.`, + ); + } + + if (size > 1048576 * 10) { + throw new Error("Maximum file size 10mb"); + } + + try { + const response = await getPresignedUrl(type, size, { + kind: uploadType, + userId: ctx.user.id, + }); + + return response; + } catch (error) { + Sentry.captureException(error); + console.error("Error getting presigned URL:", error); + throw new Error("Failed to upload image."); + } + }); diff --git a/package-lock.json b/package-lock.json index 70e3720b..358ab319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "nanoid": "^5.0.7", "next": "^14.2.10", "next-auth": "^4.24.7", + "next-safe-action": "^7.9.4", "next-themes": "^0.3.0", "nodemailer": "^6.9.14", "pg": "^8.12.0", @@ -15097,6 +15098,47 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/next-safe-action": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/next-safe-action/-/next-safe-action-7.9.4.tgz", + "integrity": "sha512-ZQtkLzaSaf+3vMAHS5G7U1nG/Nw1o++25br52J1dHiAGU0RbyE8erhoie7A5HsypmjbdTNMZotTvWLYt1PGeDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/TheEdoRan" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=ES9JRPSC66XKW" + } + ], + "engines": { + "node": ">=18.17" + }, + "peerDependencies": { + "@sinclair/typebox": ">= 0.33.3", + "next": ">= 14.0.0", + "react": ">= 18.2.0", + "react-dom": ">= 18.2.0", + "valibot": ">= 0.36.0", + "yup": ">= 1.0.0", + "zod": ">= 3.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/next-themes": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", diff --git a/package.json b/package.json index ec1a81b1..db35068f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "nanoid": "^5.0.7", "next": "^14.2.10", "next-auth": "^4.24.7", + "next-safe-action": "^7.9.4", "next-themes": "^0.3.0", "nodemailer": "^6.9.14", "pg": "^8.12.0", diff --git a/server/api/router/profile.ts b/server/api/router/profile.ts index 6c274302..ff09a25b 100644 --- a/server/api/router/profile.ts +++ b/server/api/router/profile.ts @@ -1,4 +1,4 @@ -import { user, emailChangeHistory } from "@/server/db/schema"; +import { user } from "@/server/db/schema"; import { saveSettingsSchema, getProfileSchema, @@ -20,7 +20,6 @@ import { emailTokenReqSchema } from "@/schema/token"; import { generateEmailToken, sendVerificationEmail } from "@/utils/emailToken"; import { TOKEN_EXPIRATION_TIME } from "@/config/constants"; import { emailChangeRequest } from "@/server/db/schema"; -import { z } from "zod"; export const profileRouter = createTRPCRouter({ edit: protectedProcedure diff --git a/server/lib/safeAction.ts b/server/lib/safeAction.ts new file mode 100644 index 00000000..eaa8089b --- /dev/null +++ b/server/lib/safeAction.ts @@ -0,0 +1,15 @@ +import "server-only"; + +import { createSafeActionClient } from "next-safe-action"; +import { getServerAuthSession } from "@/server/auth"; +export const actionClient = createSafeActionClient(); + +export const authActionClient = actionClient.use(async ({ next }) => { + const session = await getServerAuthSession(); + + if (!session || !session.user) { + throw new Error("Session invalid."); + } + + return next({ ctx: { user: session.user } }); +});