diff --git a/.env.example b/.env.example index 07bcad8..644a2e4 100644 --- a/.env.example +++ b/.env.example @@ -40,4 +40,8 @@ NEXT_PUBLIC_CRISP_WEBSITE_ID="" NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_HOST="" NEXT_PUBLIC_POSTHOG_PROXY_HOST="/ioafe" -POSTHOG_PROXY_PATH="ioafe" \ No newline at end of file +POSTHOG_PROXY_PATH="ioafe" + +# redis for ratelimiting +UPSTASH_REDIS_REST_URL="" +UPSTASH_REDIS_REST_TOKEN="=" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e871aa4..9c623f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "@trpc/server": "^10.18.0", "@types/recordrtc": "^5.6.11", "@upstash/qstash": "^0.3.6", + "@upstash/ratelimit": "^0.4.2", + "@upstash/redis": "^1.20.4", "axios": "^1.3.5", "crisp-sdk-web": "^1.0.18", "dayjs": "^1.11.7", @@ -2523,6 +2525,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.6.tgz", + "integrity": "sha512-cpPSR0XJAJs4Ddz9nq3tINlPS5aLfWVCqhhtHnXt4p7qr5+/Znlt1Es736poB/9rnl1hAHrOsOvVj46NEXcVqA==", + "dependencies": { + "@upstash/redis": "^1.19.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@upstash/qstash": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@upstash/qstash/-/qstash-0.3.6.tgz", @@ -2531,6 +2544,25 @@ "@deno/shim-crypto": "~0.3.0" } }, + "node_modules/@upstash/ratelimit": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-0.4.2.tgz", + "integrity": "sha512-nuuPK2IoPJywAK/8cosNiyM3vlcL6tFPaAwrLes0j2J5b1Hs/W7XDO72/AuC+K+dt89TP50WK9bvDFUwLRuhvw==", + "dependencies": { + "@upstash/core-analytics": "^0.0.6" + }, + "peerDependencies": { + "@upstash/redis": "^1.20.1" + } + }, + "node_modules/@upstash/redis": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.20.4.tgz", + "integrity": "sha512-U7j7py+yPvafB5KS7o+F19j2CWzZCwmQ4Tvs+n2lpCWuw/8CeFtWrFWPtQa5dgrVu6tu+Ki9DmhDiAbgMS5fGA==", + "dependencies": { + "isomorphic-fetch": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -4921,6 +4953,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/jiti": { "version": "1.18.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", @@ -5399,6 +5440,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -6864,6 +6924,11 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -7060,6 +7125,25 @@ "loose-envify": "^1.0.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", + "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9065,6 +9149,14 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@upstash/core-analytics": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.6.tgz", + "integrity": "sha512-cpPSR0XJAJs4Ddz9nq3tINlPS5aLfWVCqhhtHnXt4p7qr5+/Znlt1Es736poB/9rnl1hAHrOsOvVj46NEXcVqA==", + "requires": { + "@upstash/redis": "^1.19.3" + } + }, "@upstash/qstash": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@upstash/qstash/-/qstash-0.3.6.tgz", @@ -9073,6 +9165,22 @@ "@deno/shim-crypto": "~0.3.0" } }, + "@upstash/ratelimit": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-0.4.2.tgz", + "integrity": "sha512-nuuPK2IoPJywAK/8cosNiyM3vlcL6tFPaAwrLes0j2J5b1Hs/W7XDO72/AuC+K+dt89TP50WK9bvDFUwLRuhvw==", + "requires": { + "@upstash/core-analytics": "^0.0.6" + } + }, + "@upstash/redis": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.20.4.tgz", + "integrity": "sha512-U7j7py+yPvafB5KS7o+F19j2CWzZCwmQ4Tvs+n2lpCWuw/8CeFtWrFWPtQa5dgrVu6tu+Ki9DmhDiAbgMS5fGA==", + "requires": { + "isomorphic-fetch": "^3.0.0" + } + }, "acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -10823,6 +10931,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "requires": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "jiti": { "version": "1.18.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", @@ -11162,6 +11279,14 @@ "uuid": "^8.3.2" } }, + "node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, "node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -12144,6 +12269,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -12283,6 +12413,25 @@ "loose-envify": "^1.0.0" } }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-fetch": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", + "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ddcd95e..15da63d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@trpc/server": "^10.18.0", "@types/recordrtc": "^5.6.11", "@upstash/qstash": "^0.3.6", + "@upstash/ratelimit": "^0.4.2", + "@upstash/redis": "^1.20.4", "axios": "^1.3.5", "crisp-sdk-web": "^1.0.18", "dayjs": "^1.11.7", diff --git a/src/env.mjs b/src/env.mjs index b1a1133..73c5a4d 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -33,6 +33,8 @@ const server = z.object({ STRIPE_MONTHLY_PRICE_ID: z.string(), STRIPE_ANNUAL_PRICE_ID: z.string(), POSTHOG_PROXY_PATH: z.string(), + UPSTASH_REDIS_REST_URL: z.string(), + UPSTASH_REDIS_REST_TOKEN: z.string(), }); /** @@ -80,6 +82,8 @@ const processEnv = { NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, POSTHOG_PROXY_PATH: process.env.POSTHOG_PROXY_PATH, NEXT_PUBLIC_POSTHOG_PROXY_HOST: process.env.NEXT_PUBLIC_POSTHOG_PROXY_HOST, + UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, }; // Don't touch the part below diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 904eb5b..36506cc 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -81,6 +81,7 @@ import { s3 } from "~/server/aws/s3"; import { stripe } from "~/server/stripe"; import { type NextApiRequest, type NextApiResponse } from "next"; import { posthog } from "~/server/posthog"; +import { rateLimit } from "~/server/rateLimit"; const t = initTRPC.context().create({ transformer: superjson, @@ -120,10 +121,15 @@ export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; /** Reusable middleware that enforces users are logged in before running the procedure. */ -const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { +const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } + const { success } = await rateLimit.limit(ctx.session.user.id); + if (!success) { + throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); + } + return next({ ctx: { // infers the `session` as non-nullable diff --git a/src/server/rateLimit.ts b/src/server/rateLimit.ts new file mode 100644 index 0000000..f0160f6 --- /dev/null +++ b/src/server/rateLimit.ts @@ -0,0 +1,11 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { env } from "~/env.mjs"; + +export const rateLimit = new Ratelimit({ + redis: new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, + }), + limiter: Ratelimit.slidingWindow(2, "10 s"), +});