diff --git a/apps/api/package.json b/apps/api/package.json index dff2255d..35593bae 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "@fastify/compress": "^7.0.3", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^9.0.0", + "@fastify/rate-limit": "^9.1.0", "@fastify/websocket": "^8.3.1", "@openpanel/common": "workspace:*", "@openpanel/db": "workspace:*", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0f6d6a56..a7d99e83 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -209,8 +209,16 @@ const startServer = async () => { fastify.register(importRouter, { prefix: '/import' }); fastify.register(trackRouter, { prefix: '/track' }); fastify.setErrorHandler((error, request, reply) => { - request.log.error('request error', { error }); - reply.status(500).send('Internal server error'); + if (error.statusCode === 429) { + reply.status(429).send({ + status: 429, + error: 'Too Many Requests', + message: 'You have exceeded the rate limit for this endpoint.', + }); + } else { + request.log.error('request error', { error }); + reply.status(500).send('Internal server error'); + } }); fastify.get('/', (_request, reply) => { reply.send({ name: 'openpanel sdk api' }); diff --git a/apps/api/src/routes/export.router.ts b/apps/api/src/routes/export.router.ts index edc7882c..f986b54d 100644 --- a/apps/api/src/routes/export.router.ts +++ b/apps/api/src/routes/export.router.ts @@ -1,10 +1,16 @@ import * as controller from '@/controllers/export.controller'; import { validateExportRequest } from '@/utils/auth'; +import { activateRateLimiter } from '@/utils/rate-limiter'; +import { Prisma } from '@openpanel/db'; import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; -import { Prisma } from '@openpanel/db'; +const exportRouter: FastifyPluginCallback = async (fastify, opts, done) => { + await activateRateLimiter({ + fastify, + max: 10, + timeWindow: '10 seconds', + }); -const eventRouter: FastifyPluginCallback = (fastify, opts, done) => { fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { try { const client = await validateExportRequest(req.headers); @@ -43,4 +49,4 @@ const eventRouter: FastifyPluginCallback = (fastify, opts, done) => { done(); }; -export default eventRouter; +export default exportRouter; diff --git a/apps/api/src/utils/rate-limiter.ts b/apps/api/src/utils/rate-limiter.ts new file mode 100644 index 00000000..d28fe77b --- /dev/null +++ b/apps/api/src/utils/rate-limiter.ts @@ -0,0 +1,34 @@ +import { getRedisCache } from '@openpanel/redis'; +import type { FastifyInstance } from 'fastify'; + +export async function activateRateLimiter({ + fastify, + max, + timeWindow, +}: { + fastify: FastifyInstance; + max: number; + timeWindow?: string; +}) { + await fastify.register(import('@fastify/rate-limit'), { + max, + timeWindow: timeWindow || '1 minute', + errorResponseBuilder: (req, reply) => { + return { + statusCode: 429, + error: 'Too Many Requests', + message: 'You have exceeded the rate limit for this endpoint.', + }; + }, + redis: getRedisCache(), + keyGenerator(req) { + return (req.headers['openpanel-client-id'] || + req.headers['x-real-ip'] || + req.headers['x-client-ip'] || + req.headers['x-forwarded-for']) as string; + }, + onExceeded: (req, reply) => { + req.log.warn('Rate limit exceeded'); + }, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8616eb00..49d1519b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@fastify/cors': specifier: ^9.0.0 version: 9.0.1 + '@fastify/rate-limit': + specifier: ^9.1.0 + version: 9.1.0 '@fastify/websocket': specifier: ^8.3.1 version: 8.3.1 @@ -3805,6 +3808,14 @@ packages: fast-deep-equal: 3.1.3 dev: false + /@fastify/rate-limit@9.1.0: + resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==} + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 4.5.1 + toad-cache: 3.7.0 + dev: false + /@fastify/websocket@8.3.1: resolution: {integrity: sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==} dependencies: @@ -4311,6 +4322,11 @@ packages: resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} dev: false + /@lukeed/ms@2.0.2: + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + dev: false + /@mapbox/node-pre-gyp@1.0.11: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true