diff --git a/apps/web/package.json b/apps/web/package.json index e138453c2..3d26b6526 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,8 @@ "@trpc/client": "^10.13.0", "@trpc/next": "^10.13.0", "@trpc/react-query": "^10.13.0", + "@upstash/ratelimit": "^1.2.1", + "@vercel/kv": "^2.0.0", "@vercel/functions": "^1.0.2", "accept-language-parser": "^1.5.0", "autoprefixer": "^10.4.13", diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts index 6185d141f..858f8bb78 100644 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -2,6 +2,8 @@ import { createTRPCContext } from "@rallly/backend/trpc/context"; import { AppRouter, appRouter } from "@rallly/backend/trpc/routers"; import * as Sentry from "@sentry/nextjs"; import { createNextApiHandler } from "@trpc/server/adapters/next"; +import { Ratelimit } from "@upstash/ratelimit"; +import { kv } from "@vercel/kv"; import { posthog, posthogApiHandler } from "@/app/posthog"; import { absoluteUrl, shortUrl } from "@/utils/absolute-url"; @@ -10,6 +12,12 @@ import { isSelfHosted } from "@/utils/constants"; import { emailClient } from "@/utils/emails"; import { composeApiHandlers } from "@/utils/next"; +const ratelimit = new Ratelimit({ + redis: kv, + // 5 requests from the same user in 10 seconds + limiter: Ratelimit.slidingWindow(5, "10 s"), +}); + export const config = { api: { externalResolver: true, @@ -38,6 +46,12 @@ const trpcApiHandler = createNextApiHandler({ isEmailBlocked, absoluteUrl, shortUrl, + ratelimit: async (key: string) => { + if (!process.env.KV_REST_API_URL) { + return { success: true }; + } + return ratelimit.limit(key); + }, }); return res; diff --git a/packages/backend/trpc/context.ts b/packages/backend/trpc/context.ts index 9ed6ac496..1983d63b9 100644 --- a/packages/backend/trpc/context.ts +++ b/packages/backend/trpc/context.ts @@ -21,6 +21,7 @@ export interface TRPCContextParams { */ absoluteUrl: (path?: string) => string; shortUrl: (path?: string) => string; + ratelimit: (key: string) => Promise<{ success: boolean }>; } export const createTRPCContext = async ( diff --git a/packages/backend/trpc/routers/auth.ts b/packages/backend/trpc/routers/auth.ts index bb33c6efa..5b2a9e4f8 100644 --- a/packages/backend/trpc/routers/auth.ts +++ b/packages/backend/trpc/routers/auth.ts @@ -1,4 +1,5 @@ import { prisma } from "@rallly/database"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createToken, decryptToken } from "../../session"; @@ -23,6 +24,16 @@ export const auth = router({ | { ok: true; token: string } | { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" } > => { + if (process.env.KV_REST_API_URL) { + const { success } = await ctx.ratelimit(ctx.user.id); + if (!success) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many requests", + }); + } + } + if (ctx.isEmailBlocked?.(input.email)) { return { ok: false, reason: "emailNotAllowed" }; } @@ -50,10 +61,9 @@ export const auth = router({ await ctx.emailClient.sendTemplate("RegisterEmail", { to: input.email, - subject: `${input.name}, please verify your email address`, + subject: "Please verify your email address", props: { code, - name: input.name, }, }); diff --git a/packages/backend/trpc/routers/polls/participants.ts b/packages/backend/trpc/routers/polls/participants.ts index 9111e4e3e..ad2303884 100644 --- a/packages/backend/trpc/routers/polls/participants.ts +++ b/packages/backend/trpc/routers/polls/participants.ts @@ -64,6 +64,14 @@ export const participants = router({ }), ) .mutation(async ({ ctx, input: { pollId, votes, name, email } }) => { + const { success } = await ctx.ratelimit(ctx.user.id); + + if (!success) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "You are doing that too much", + }); + } const { user } = ctx; const poll = await prisma.poll.findUnique({ diff --git a/packages/emails/src/templates/_components/email-layout.tsx b/packages/emails/src/templates/_components/email-layout.tsx index ba0a07162..4f7a65292 100644 --- a/packages/emails/src/templates/_components/email-layout.tsx +++ b/packages/emails/src/templates/_components/email-layout.tsx @@ -13,7 +13,7 @@ import { fontFamily, Section, Text } from "./styled-components"; export interface EmailLayoutProps { preview: string; - recipientName: string; + recipientName?: string; footNote?: React.ReactNode; ctx: EmailContext; } @@ -40,7 +40,7 @@ const linkStyles = { export const EmailLayout = ({ preview, - recipientName = "Guest", + recipientName, children, footNote, ctx, @@ -60,7 +60,7 @@ export const EmailLayout = ({ Rallly
- Hi {recipientName}, + {recipientName ? Hi {recipientName}, : null} {children} {footNote ? ( { @@ -28,7 +26,6 @@ export const RegisterEmail = ({ please ignore this email. } - recipientName={name} preview={`Your 6-digit code is: ${code}`} > diff --git a/turbo.json b/turbo.json index d48061c6c..ff18b19a5 100644 --- a/turbo.json +++ b/turbo.json @@ -119,6 +119,7 @@ "STRIPE_SECRET_KEY", "STRIPE_SIGNING_SECRET", "STRIPE_YEARLY_PRICE", - "SUPPORT_EMAIL" + "SUPPORT_EMAIL", + "KV_REST_API_URL" ] } diff --git a/yarn.lock b/yarn.lock index 457b39622..b31b6fa01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4715,6 +4715,27 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@upstash/core-analytics@^0.0.9": + version "0.0.9" + resolved "https://registry.yarnpkg.com/@upstash/core-analytics/-/core-analytics-0.0.9.tgz#59f29a920099084e049726c0aca67be1c1ecfc1f" + integrity sha512-9NXXxZ5y1/A/zqKLlVT7NsAWSggJfOjB0hG6Ffx29b4jbzHOiQVWB55h5+j2clT9Ib+mNPXn0iB5zN3aWLkICw== + dependencies: + "@upstash/redis" "^1.28.3" + +"@upstash/ratelimit@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@upstash/ratelimit/-/ratelimit-1.2.1.tgz#835a33ce715e999d646431f70a71a69de7d439ee" + integrity sha512-o01lV1yFS5Fzj5KONZmNyVch/Qrlj785B2ob+kStUmxn8F6xXk7IHTQqVcHE+Ce3CmT/qQIwvMxDZftyJ5wYpQ== + dependencies: + "@upstash/core-analytics" "^0.0.9" + +"@upstash/redis@^1.28.3", "@upstash/redis@^1.31.3": + version "1.31.5" + resolved "https://registry.yarnpkg.com/@upstash/redis/-/redis-1.31.5.tgz#8d5fe439a2a28638b3a354a23680ecf7f7eb4f54" + integrity sha512-2MatqeqftroSJ9Q+pqbyGAIwXX6KEPtUTUna2c/fq09h12ffwvltDTgfppeF+NzJo/SyZfHY8e1RoflduMbz1A== + dependencies: + crypto-js "^4.2.0" + "@vercel/analytics@^0.1.8": version "0.1.11" resolved "https://registry.npmjs.org/@vercel/analytics/-/analytics-0.1.11.tgz" @@ -4725,6 +4746,13 @@ resolved "https://registry.yarnpkg.com/@vercel/functions/-/functions-1.0.2.tgz#c26ed4e3b0ed701e28c4ebd71c76b1bfe14db02a" integrity sha512-j3udyHOv/05Y8o3WQ/ANMWa1aYagsY5B3ouImiwgYsz5z4CBUHTY5dk74oQAXYr+bgoVDpdDlmxkpnxGzKEdLQ== +"@vercel/kv@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@vercel/kv/-/kv-2.0.0.tgz#a0baa12563946cb35cee23d638b68f0fbbf76172" + integrity sha512-zdVrhbzZBYo5d1Hfn4bKtqCeKf0FuzW8rSHauzQVMUgv1+1JOwof2mWcBuI+YMJy8s0G0oqAUfQ7HgUDzb8EbA== + dependencies: + "@upstash/redis" "^1.31.3" + "@vitest/expect@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.3.1.tgz#d4c14b89c43a25fd400a6b941f51ba27fe0cb918" @@ -5991,6 +6019,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + crypto@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz"