diff --git a/docker-compose.yml b/docker-compose.yml index 334a6d43..ac2fed00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,9 @@ services: MONGO_REPLICA_HOST: mongo MONGO_REPLICA_PORT: 27017 ports: - - "27017:27017" - - command: mongod --quiet --logpath /dev/null + - '27017:27017' + + command: mongod --quiet --logpath /dev/null networks: projectmate-network: @@ -26,6 +26,9 @@ services: dockerfile: ./docker/web/Dockerfile container_name: web restart: always + environment: + UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL} + UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN} ports: - 3000:3000 networks: @@ -37,4 +40,4 @@ services: command: sh -c "rsync -arv /usr/src/cache/node_modules/. /usr/src/app/node_modules/ && yarn dev" networks: projectmate-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/lib/rateLimiter.ts b/lib/rateLimiter.ts new file mode 100644 index 00000000..56f7b4ab --- /dev/null +++ b/lib/rateLimiter.ts @@ -0,0 +1,18 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis'; + +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, +}); + +const reqNum = 10; +const reqTime = '1 m'; +const reqTimeout = 1000; + +export const apiLimiter = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(reqNum, reqTime), + analytics: true, + timeout: reqTimeout, +}); diff --git a/lib/withRateLimit.ts b/lib/withRateLimit.ts new file mode 100644 index 00000000..3abee8cd --- /dev/null +++ b/lib/withRateLimit.ts @@ -0,0 +1,21 @@ +import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'; +import { apiLimiter } from './rateLimiter'; + +export const withRateLimit = (handler: NextApiHandler): NextApiHandler => { + return async (req: NextApiRequest, res: NextApiResponse) => { + const ip = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + req.socket.remoteAddress || + '127.0.0.1'; + const { success } = await apiLimiter.limit(ip); + + if (!success) { + return res.status(429).json({ + error: 'Rate Limited', + message: 'You have exceeded the rate limit. Please wait and try again.', + }); + } + + return handler(req, res); + }; +}; diff --git a/package.json b/package.json index 0e5f40d1..199396f4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.3", "axios": "^1.3.4", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", diff --git a/pages/api/project/[postId].ts b/pages/api/project/[postId].ts index 592f2ab9..945d02b3 100644 --- a/pages/api/project/[postId].ts +++ b/pages/api/project/[postId].ts @@ -2,11 +2,9 @@ import { Project } from '@prisma/client'; import { NextApiRequest, NextApiResponse } from 'next'; import { errorResponse, successResponse } from '@/lib/httpResponse'; import { prisma } from '@/lib/prisma'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { switch (req.method) { case 'GET': const { postId } = req.query; @@ -74,3 +72,5 @@ async function getPostById(id?: string) { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/project/index.ts b/pages/api/project/index.ts index 838c91aa..e8a89726 100644 --- a/pages/api/project/index.ts +++ b/pages/api/project/index.ts @@ -9,11 +9,9 @@ import { prisma } from '@/lib/prisma'; import bodyValidator from '@/lib/bodyValidator'; import { postSchema } from '@/schema/index'; import { getServerAuthSession } from '@/lib/getServerAuthSession'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { switch (req.method) { case 'GET': const { limit, cursorId } = req.query; @@ -178,3 +176,5 @@ async function addProject(args: { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/tags/index.ts b/pages/api/tags/index.ts index 8d5ca649..1fd5b832 100644 --- a/pages/api/tags/index.ts +++ b/pages/api/tags/index.ts @@ -1,11 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { errorResponse, successResponse } from '@/lib/httpResponse'; import { prisma } from '@/lib/prisma'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { switch (req.method) { case 'GET': try { @@ -62,3 +60,5 @@ async function getAllTags() { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/tprojectsapi/[tag].ts b/pages/api/tprojectsapi/[tag].ts index be58155f..c67e75af 100644 --- a/pages/api/tprojectsapi/[tag].ts +++ b/pages/api/tprojectsapi/[tag].ts @@ -1,11 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { prisma } from '@/lib/prisma'; import { errorResponse, successResponse } from '@/lib/httpResponse'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { switch (req.method) { case 'GET': const { tag } = req.query; @@ -83,3 +81,5 @@ async function getPostById(tag: string | string[] | undefined) { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/user/[username].ts b/pages/api/user/[username].ts index 0b0e25ed..854cda41 100644 --- a/pages/api/user/[username].ts +++ b/pages/api/user/[username].ts @@ -2,11 +2,9 @@ import { errorResponse, successResponse } from '@/lib/httpResponse'; import { NextApiRequest, NextApiResponse } from 'next'; import { prisma } from '@/lib/prisma'; import { Prisma } from '@prisma/client'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { const { username } = req.query; @@ -76,3 +74,5 @@ async function getUserDetailsByUsername(username: string) { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/user/all.ts b/pages/api/user/all.ts index 9fb91732..fe3cd97a 100644 --- a/pages/api/user/all.ts +++ b/pages/api/user/all.ts @@ -3,11 +3,9 @@ import { prisma } from '@/lib/prisma'; import { User } from '@prisma/client'; import { errorResponse } from '@/lib/httpResponse'; import { getUsersWithProjects } from '@/lib/user'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { switch (req.method) { case 'GET': const { limit, cursorId } = req.query; @@ -64,3 +62,5 @@ async function getAllUsers(args: { limit: number; cursorId?: string }) { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/user/details.ts b/pages/api/user/details.ts index a6baa651..f3076495 100644 --- a/pages/api/user/details.ts +++ b/pages/api/user/details.ts @@ -10,11 +10,9 @@ import { prisma } from '@/lib/prisma'; import { Prisma } from '@prisma/client'; import bodyValidator from '@/lib/bodyValidator'; import { userDetailsSchema } from '@/schema/userDetailsSchema'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getServerAuthSession({ req, res }); if (!session) { return errorResponse({ @@ -178,3 +176,5 @@ async function updateUserDetails(args: EditableUserDetails, session: Session) { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/user/project/[username].tsx b/pages/api/user/project/[username].tsx index 6c6cfa0c..1f2bb3d2 100644 --- a/pages/api/user/project/[username].tsx +++ b/pages/api/user/project/[username].tsx @@ -2,11 +2,9 @@ import { errorResponse, successResponse } from '@/lib/httpResponse'; import { Project } from '@prisma/client'; import { NextApiRequest, NextApiResponse } from 'next'; import { prisma } from '@/lib/prisma'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { switch (req.method) { case 'GET': try { @@ -67,3 +65,5 @@ async function getProjectsByUsername(username: string) { throw error; } } + +export default withRateLimit(handler); diff --git a/pages/api/user/project/index.ts b/pages/api/user/project/index.ts index cc164df7..b9bc6247 100644 --- a/pages/api/user/project/index.ts +++ b/pages/api/user/project/index.ts @@ -4,11 +4,9 @@ import { Project } from '@prisma/client'; import { NextApiRequest, NextApiResponse } from 'next'; import { Session } from 'next-auth'; import { prisma } from '@/lib/prisma'; +import { withRateLimit } from '@/lib/withRateLimit'; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getServerAuthSession({ req, res }); if (!session) { return errorResponse({ @@ -64,3 +62,5 @@ async function getProject(session: Session) { throw error; } } + +export default withRateLimit(handler); diff --git a/yarn.lock b/yarn.lock index 16110ec3..434ce507 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1257,6 +1257,27 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== +"@upstash/core-analytics@^0.0.10": + version "0.0.10" + resolved "https://registry.yarnpkg.com/@upstash/core-analytics/-/core-analytics-0.0.10.tgz#e686a313ec2279d5a8d53e6c215085f1c0f5ab4b" + integrity sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ== + dependencies: + "@upstash/redis" "^1.28.3" + +"@upstash/ratelimit@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@upstash/ratelimit/-/ratelimit-2.0.6.tgz#d51aaae8145c7f27bc2bc593e813472388b13008" + integrity sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg== + dependencies: + "@upstash/core-analytics" "^0.0.10" + +"@upstash/redis@^1.28.3", "@upstash/redis@^1.35.3": + version "1.35.3" + resolved "https://registry.yarnpkg.com/@upstash/redis/-/redis-1.35.3.tgz#7b383b6a8af57e619c054bdccdb7a6159860cdf5" + integrity sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w== + dependencies: + uncrypto "^0.1.3" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -4228,7 +4249,16 @@ string-argv@0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4314,7 +4344,14 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4600,6 +4637,11 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" +uncrypto@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/uncrypto/-/uncrypto-0.1.3.tgz#e1288d609226f2d02d8d69ee861fa20d8348ef2b" + integrity sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q== + undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"