diff --git a/README.md b/README.md index 5cc21337..aecf0617 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,21 @@ For an overview of the community storage providers, see [Community Storage Provi This package comes with a couple of goodies that should be mentioned, first is the `ThrottlerModule`. +# Table of Contents + +- [NestJS Throttler Package](#nestjs-throttler-package) +- [Table of Contents](#table-of-contents) +- [Usage](#usage) + - [ThrottlerModule](#throttlermodule) + - [Decorators](#decorators) + - [@Throttle()](#throttle) + - [@SkipThrottle()](#skipthrottle) + - [Ignoring specific user agents](#ignoring-specific-user-agents) + - [ThrottlerStorage](#throttlerstorage) +- [Community Storage Providers](#community-storage-providers) + +# Usage + ## ThrottlerModule The `ThrottleModule` is the main entry point for this package, and can be used @@ -88,8 +103,6 @@ export class AppController { } ``` - - ## Decorators ### @Throttle() @@ -108,7 +121,8 @@ and routes. @SkipThrottle(skip = true) ``` -This decorator can be used to skip a route or a class **or** to negate the skipping of a route in a class that is skipped. +This decorator can be used to skip a route or a class **or** to negate the +skipping of a route in a class that is skipped. ```ts @SkipThrottle() @@ -121,13 +135,42 @@ export class AppController { } ``` -In the above controller, `dontSkip` would be counted against and rate-limited while `doSkip` would not be limited in any way. +In the above controller, `dontSkip` would be counted against and rate-limited +while `doSkip` would not be limited in any way. + +## Ignoring specific user agents + +You can use the `ignoreUserAgents` key to ignore specific user agents. + +```ts +@Module({ + imports: [ + ThrottlerModule.forRoot({ + ttl: 60, + limit: 10, + ignoreUserAgents: [ + // Don't throttle request that have 'googlebot' defined in them. + // Example user agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) + /googlebot/gi, + + // Don't throttle request that have 'bingbot' defined in them. + // Example user agent: Mozilla/5.0 (compatible; Bingbot/2.0; +http://www.bing.com/bingbot.htm) + new RegExp('Bingbot', 'gi'), + ], + }), + ], +}) +export class AppModule {} +``` ## ThrottlerStorage Interface to define the methods to handle the details when it comes to keeping track of the requests. -Currently the key is seen as an `MD5` hash of the `IP` the `ClassName` and the `MethodName`, to ensure that no unsafe characters are used and to ensure that the package works for contexts that don't have explicit routes (like Websockets and GraphQL). +Currently the key is seen as an `MD5` hash of the `IP` the `ClassName` and the +`MethodName`, to ensure that no unsafe characters are used and to ensure that +the package works for contexts that don't have explicit routes (like Websockets +and GraphQL). The interface looks like this: diff --git a/src/throttler.guard.ts b/src/throttler.guard.ts index b2fe922e..9fb3566a 100644 --- a/src/throttler.guard.ts +++ b/src/throttler.guard.ts @@ -20,6 +20,7 @@ export class ThrottlerGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); const handler = context.getHandler(); const classRef = context.getClass(); const headerPrefix = 'X-RateLimit'; @@ -29,6 +30,15 @@ export class ThrottlerGuard implements CanActivate { return true; } + // Return early if the current route should be ignored. + if (Array.isArray(this.options.ignoreUserAgents)) { + for (const pattern of this.options.ignoreUserAgents) { + if (pattern.test(req.headers['user-agent'])) { + return true; + } + } + } + // Return early when we have no limit or ttl data. const routeOrClassLimit = this.reflector.getAllAndOverride(THROTTLER_LIMIT, [ handler, @@ -44,7 +54,6 @@ export class ThrottlerGuard implements CanActivate { const ttl = routeOrClassTtl || this.options.ttl; // Here we start to check the amount of requests being done against the ttl. - const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); const key = md5(`${req.ip}-${classRef.name}-${handler.name}`); const ttls = await this.storageService.getRecord(key); diff --git a/src/throttler.interface.ts b/src/throttler.interface.ts index 38f85094..2c0f2a2f 100644 --- a/src/throttler.interface.ts +++ b/src/throttler.interface.ts @@ -3,5 +3,6 @@ import { ThrottlerStorage } from './throttler-storage.interface'; export interface ThrottlerOptions { limit: number; ttl: number; + ignoreUserAgents?: RegExp[]; storage?: ThrottlerStorage; } diff --git a/test/app/app.module.ts b/test/app/app.module.ts index 4c63f83b..fe3e9bfd 100644 --- a/test/app/app.module.ts +++ b/test/app/app.module.ts @@ -2,11 +2,11 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerGuard } from '../../src'; import { ControllerModule } from './controllers/controller.module'; -import { GatewayModule } from './gateways/gateway.module'; -import { ResolverModule } from './resolvers/resolver.module'; +// import { GatewayModule } from './gateways/gateway.module'; +// import { ResolverModule } from './resolvers/resolver.module'; @Module({ - imports: [ControllerModule, GatewayModule, ResolverModule], + imports: [ControllerModule /* , GatewayModule, ResolverModule */], providers: [ { provide: APP_GUARD, diff --git a/test/app/controllers/app.controller.ts b/test/app/controllers/app.controller.ts index e1722bbf..4a322c5c 100644 --- a/test/app/controllers/app.controller.ts +++ b/test/app/controllers/app.controller.ts @@ -17,4 +17,9 @@ export class AppController { async ignored() { return this.appService.ignored(); } + + @Get('ignore-user-agents') + async ignoreUserAgents() { + return this.appService.ignored(); + } } diff --git a/test/app/controllers/controller.module.ts b/test/app/controllers/controller.module.ts index d2a1ded8..36cc4bab 100644 --- a/test/app/controllers/controller.module.ts +++ b/test/app/controllers/controller.module.ts @@ -10,6 +10,7 @@ import { LimitController } from './limit.controller'; ThrottlerModule.forRoot({ limit: 5, ttl: 60, + ignoreUserAgents: [/throttler-test/g], }), ], controllers: [AppController, DefaultController, LimitController], diff --git a/test/app/main.ts b/test/app/main.ts index 2c8d880e..2633cac9 100644 --- a/test/app/main.ts +++ b/test/app/main.ts @@ -1,13 +1,16 @@ import { NestFactory } from '@nestjs/core'; -import { FastifyAdapter } from '@nestjs/platform-fastify'; +// import { FastifyAdapter } from '@nestjs/platform-fastify'; +import { ExpressAdapter } from '@nestjs/platform-express'; +// import { WsAdapter } from '@nestjs/platform-ws'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create( AppModule, - // new ExpressAdapter(), - new FastifyAdapter(), + new ExpressAdapter(), + // new FastifyAdapter(), ); + // app.useWebSocketAdapter(new WsAdapter(app)) await app.listen(3000); } bootstrap(); diff --git a/test/controller.e2e-spec.ts b/test/controller.e2e-spec.ts index 79038d34..2de4e41e 100644 --- a/test/controller.e2e-spec.ts +++ b/test/controller.e2e-spec.ts @@ -3,9 +3,9 @@ import { AbstractHttpAdapter, APP_GUARD } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; import { FastifyAdapter } from '@nestjs/platform-fastify'; import { Test, TestingModule } from '@nestjs/testing'; +import { ThrottlerGuard } from '../src'; import { ControllerModule } from './app/controllers/controller.module'; import { httPromise } from './utility/httpromise'; -import { ThrottlerGuard } from '../src'; describe.each` adapter | adapterName @@ -52,6 +52,17 @@ describe.each` 'x-ratelimit-reset': /\d+/, }); }); + it('GET /ignore-user-agents', async () => { + const response = await httPromise(appUrl + '/ignore-user-agents', 'GET', { + 'user-agent': 'throttler-test/0.0.0', + }); + expect(response.data).toEqual({ ignored: true }); + expect(response.headers).not.toMatchObject({ + 'x-ratelimit-limit': '2', + 'x-ratelimit-remaining': '1', + 'x-ratelimit-reset': /\d+/, + }); + }); it('GET /', async () => { const response = await httPromise(appUrl + '/'); expect(response.data).toEqual({ success: true }); diff --git a/test/utility/httpromise.ts b/test/utility/httpromise.ts index 5ec83ad0..87a714cd 100644 --- a/test/utility/httpromise.ts +++ b/test/utility/httpromise.ts @@ -5,6 +5,7 @@ type HttpMethods = 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; export function httPromise( url: string, method: HttpMethods = 'GET', + headers: Record = {}, body?: Record, ): Promise<{ data: any; headers: Record; status: number }> { return new Promise((resolve, reject) => { @@ -30,6 +31,11 @@ export function httPromise( }); }); req.method = method; + + Object.keys(headers).forEach((key) => { + req.setHeader(key, headers[key]); + }); + switch (method) { case 'GET': break;