diff --git a/spec/throttler.test.ts b/spec/throttler.test.ts new file mode 100644 index 0000000..3120a0c --- /dev/null +++ b/spec/throttler.test.ts @@ -0,0 +1,52 @@ +import { assertEquals } from '../src/deps_test.ts'; +import { DanetApplication } from '../src/app.ts'; +import { Controller, Get } from '../src/router/controller/decorator.ts'; +import { Module } from '../src/module/decorator.ts'; +import { Throttle } from '../src/throttler/decorator.ts'; +import { ThrottlerService, ThrottleGuard } from '../src/throttler/mod.ts'; +import { GLOBAL_GUARD } from '../src/guard/constants.ts'; + +@Controller('throttle') +class ThrottleController { + @Throttle(2, 1) + @Get('/') + simpleGet() { + return { ok: true }; + } +} + +@Module({ + imports: [], + controllers: [ThrottleController], + injectables: [ + ThrottlerService, + { useClass: ThrottleGuard, token: GLOBAL_GUARD }, + ], +}) +class ThrottleModule {} + +Deno.test('throttle guard limits requests', async () => { + const app = new DanetApplication(); + await app.init(ThrottleModule); + const listenEvent = await app.listen(0); + const base = `http://localhost:${listenEvent.port}/throttle`; + + const r1 = await fetch(base, { method: 'GET' }); + assertEquals(r1.status, 200); + await r1?.body?.cancel(); + + const r2 = await fetch(base, { method: 'GET' }); + assertEquals(r2.status, 200); + await r2?.body?.cancel(); + + const r3 = await fetch(base, { method: 'GET' }); + assertEquals(r3.status, 429); + const json = await r3.json(); + assertEquals(json, { + description: 'Too many requests', + message: '429 - Too many requests', + name: 'TooManyRequestsException', + status: 429, + }); + await app.close(); +}); diff --git a/src/mod.ts b/src/mod.ts index f0ec7be..0fb21b2 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -15,4 +15,5 @@ export * from './logger.ts'; export * from './events/mod.ts'; export * from './schedule/mod.ts'; export * from './kv-queue/mod.ts'; -export * from './sse/mod.ts'; \ No newline at end of file +export * from './sse/mod.ts'; +export * from './throttler/mod.ts'; \ No newline at end of file diff --git a/src/throttler/decorator.ts b/src/throttler/decorator.ts new file mode 100644 index 0000000..484c260 --- /dev/null +++ b/src/throttler/decorator.ts @@ -0,0 +1,20 @@ +import { MetadataFunction, SetMetadata } from '../metadata/decorator.ts'; + +export const throttleMetadataKey = 'throttle'; + +/** + * Options for throttle metadata. + */ +export interface ThrottleOptions { + limit: number; + ttl?: number; // seconds +} + +/** + * Apply throttle options to controller or handler. + * @param limit number of requests + * @param ttl seconds window + */ +export function Throttle(limit: number, ttl = 60): MetadataFunction { + return SetMetadata(throttleMetadataKey, { limit, ttl } as ThrottleOptions); +} diff --git a/src/throttler/guard.ts b/src/throttler/guard.ts new file mode 100644 index 0000000..bc4fa47 --- /dev/null +++ b/src/throttler/guard.ts @@ -0,0 +1,44 @@ +import { AuthGuard } from '../guard/interface.ts'; +import { ExecutionContext } from '../router/router.ts'; +import { MetadataHelper } from '../metadata/helper.ts'; +import { throttleMetadataKey, ThrottleOptions } from './decorator.ts'; +import { ThrottlerService } from './service.ts'; +import { Injectable } from '../injector/injectable/decorator.ts'; +import { TooManyRequestsException } from '../exception/http/exceptions.ts'; + +/** + * A guard that checks throttle metadata (method/controller) and enforces limits. + */ +@Injectable() +export class ThrottleGuard implements AuthGuard { + constructor(private throttler: ThrottlerService) {} + +canActivate(context: ExecutionContext) { + const handler = context.getHandler(); + const controller = context.getClass(); + + const methodOptions = MetadataHelper.getMetadata( + throttleMetadataKey, + handler, + ); + const controllerOptions = MetadataHelper.getMetadata( + throttleMetadataKey, + controller, + ); + const options = methodOptions || controllerOptions; + if (!options) { + // nothing to do + return true; + } + + // default key: X-Forwarded-For header, fallbacks to remote address or global + const ip = context.req.header('x-forwarded-for') || context.req.header('x-real-ip') || 'global'; + const key = `${ip}:${context.getHandler().name}`; + + const count = this.throttler.consume(key, options.ttl || 60); + if (count > options.limit) { + throw new TooManyRequestsException(); + } + return true; + } +} diff --git a/src/throttler/mod.ts b/src/throttler/mod.ts new file mode 100644 index 0000000..8b9b897 --- /dev/null +++ b/src/throttler/mod.ts @@ -0,0 +1,3 @@ +export * from './decorator.ts'; +export * from './guard.ts'; +export * from './service.ts'; diff --git a/src/throttler/service.ts b/src/throttler/service.ts new file mode 100644 index 0000000..f9957f5 --- /dev/null +++ b/src/throttler/service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '../injector/injectable/decorator.ts'; + +/** + * Very small in-memory throttler. + * Stores timestamps per key and prunes them on each hit. + */ +@Injectable() +export class ThrottlerService { + private store = new Map(); + + /** + * Returns current count after adding this request timestamp. + * Prunes entries older than ttl seconds. + */ + consume(key: string, ttl: number): number { + const now = Date.now(); + const windowStart = now - ttl * 1000; + const list = this.store.get(key) || []; + const pruned = list.filter((ts) => ts > windowStart); + pruned.push(now); + this.store.set(key, pruned); + return pruned.length; + } + + /** + * For tests and diagnostics: reset store + */ + reset() { + this.store.clear(); + } +}