UNPKG

@adonisjs/limiter

Version:

Rate limiting package for AdonisJS framework

572 lines (563 loc) 16.6 kB
import { LimiterMemoryStore } from "./chunk-C22TWIEE.js"; import { E_TOO_MANY_REQUESTS, debug_default } from "./chunk-RWNA5JOS.js"; // configure.ts import string from "@adonisjs/core/helpers/string"; // stubs/main.ts import { getDirname } from "@adonisjs/core/helpers"; var stubsRoot = getDirname(import.meta.url); // configure.ts var KNOWN_STORES = ["database", "redis"]; async function configure(command) { let selectedStore = command.parsedFlags.store; if (!selectedStore) { selectedStore = await command.prompt.choice( "Select the storage layer you want to use", KNOWN_STORES, { validate(value) { return !value ? "Please select a store" : true; } } ); } if (!KNOWN_STORES.includes(selectedStore)) { command.exitCode = 1; command.logger.logError( `Invalid limiter store "${selectedStore}". Supported stores are: ${string.sentence( KNOWN_STORES )}` ); return; } const codemods = await command.createCodemods(); await codemods.makeUsingStub(stubsRoot, "config/limiter.stub", { store: selectedStore }); await codemods.makeUsingStub(stubsRoot, "start/limiter.stub", {}); await codemods.updateRcFile((rcFile) => { rcFile.addProvider("@adonisjs/limiter/limiter_provider"); }); if (selectedStore === "database") { await codemods.makeUsingStub(stubsRoot, "make/migration/rate_limits.stub", { entity: command.app.generators.createEntity("rate_limits"), migration: { folder: "database/migrations", fileName: `${(/* @__PURE__ */ new Date()).getTime()}_create_rate_limits_table.ts` } }); } await codemods.defineEnvVariables({ LIMITER_STORE: selectedStore }); await codemods.defineEnvValidations({ leadingComment: "Variables for configuring the limiter package", variables: { LIMITER_STORE: `Env.schema.enum(['${selectedStore}', 'memory'] as const)` } }); } // src/limiter.ts var Limiter = class { #store; /** * The number of configured requests on the store */ get name() { return this.#store.name; } /** * The number of configured requests on the store */ get requests() { return this.#store.requests; } /** * The duration (in seconds) for which the requests are configured */ get duration() { return this.#store.duration; } /** * The duration (in seconds) for which to block the key */ get blockDuration() { return this.#store.blockDuration; } constructor(store) { this.#store = store; } /** * Consume 1 request for a given key. An exception is raised * when all the requests have already been consumed or if * the key is blocked. */ consume(key) { return this.#store.consume(key); } /** * Increment the number of consumed requests for a given key. * No errors are thrown when limit has reached */ increment(key) { return this.#store.increment(key); } /** * Decrement the number of consumed requests for a given key. */ decrement(key) { return this.#store.decrement(key); } /** * Consume 1 request for a given key and execute the provided * callback. */ async attempt(key, callback) { const response = await this.get(key); if (response && response.consumed > response.limit) { return; } try { await this.consume(key); return callback(); } catch (error) { if (error instanceof E_TOO_MANY_REQUESTS === false) { throw error; } } } /** * Consume 1 request for a given key when the executed method throws * an error. * * - Check if all the requests have been exhausted. If yes, throw limiter * error. * - Otherwise, execute the provided callback. * - Increment the requests counter, if provided callback throws an error and rethrow * the error * - Delete key, if the provided callback succeeds and return the results. */ async penalize(key, callback) { const response = await this.get(key); if (response && response.remaining <= 0) { return [new E_TOO_MANY_REQUESTS(response), null]; } let callbackResult; let callbackError; try { callbackResult = await callback(); } catch (error) { callbackError = error; } if (callbackError) { const { consumed, limit } = await this.increment(key); if (consumed >= limit && this.blockDuration) { await this.block(key, this.blockDuration); } throw callbackError; } await this.delete(key); return [null, callbackResult]; } /** * Block a given key for the given duration. The duration must be * a value in seconds or a string expression. */ block(key, duration) { return this.#store.block(key, duration); } /** * Manually set the number of requests exhausted for * a given key for the given time duration. * * For example: "ip_127.0.0.1" has made "20 requests" in "1 minute". * Now, if you allow 25 requests in 1 minute, then only 5 requests * are left. * * The duration must be a value in seconds or a string expression. */ set(key, requests, duration) { return this.#store.set(key, requests, duration); } /** * Delete a given key */ delete(key) { return this.#store.delete(key); } /** * Delete all keys blocked within the memory */ deleteInMemoryBlockedKeys() { return this.#store.deleteInMemoryBlockedKeys?.(); } /** * Get limiter response for a given key. Returns null when * key doesn't exist. */ get(key) { return this.#store.get(key); } /** * Find the number of remaining requests for a given key */ async remaining(key) { const response = await this.get(key); if (!response) { return this.requests; } return response.remaining; } /** * Find the number of seconds remaining until the key will * be available for new request */ async availableIn(key) { const response = await this.get(key); if (!response) { return 0; } return response.remaining === 0 ? response.availableIn : 0; } /** * Find if the current key is blocked. This method checks * if the consumed points are equal to or greater than * the allowed limit. */ async isBlocked(key) { const response = await this.get(key); if (!response) { return false; } return response.consumed >= response.limit; } /** * Clear the storage database */ clear() { return this.#store.clear(); } }; // src/http_limiter.ts import { RuntimeException } from "@adonisjs/core/exceptions"; var HttpLimiter = class { /** * The manager reference to create limiter instances * for a given store */ #manager; /** * The runtime options configured using the fluent * API */ #options; /** * The selected store. Otherwise the default store will * be used */ #store; /** * The key to unique identify the user. Defaults to "request.ip" */ #key; /** * A custom callback function to modify error messages. */ #exceptionModifier = () => { }; constructor(manager, options) { this.#manager = manager; this.#options = options || {}; } /** * Specify the store you want to use during * the request */ store(store) { this.#store = store; return this; } /** * Specify the number of requests to allow */ allowRequests(requests) { this.#options.requests = requests; return this; } /** * Specify the duration in seconds or a time expression * for which the requests to allow. * * For example: allowRequests(10).every('1 minute') */ every(duration) { this.#options.duration = duration; return this; } /** * Specify a custom unique key to identify the user. * Defaults to: request.ip() */ usingKey(key) { this.#key = key; return this; } /** * Register a callback function to modify the ThrottleException. */ limitExceeded(callback) { this.#exceptionModifier = callback; return this; } /** * Define the block duration. The key will be blocked for the * specified duration after all the requests have been * exhausted */ blockFor(duration) { this.#options.blockDuration = duration; return this; } /** * JSON representation of the HTTP limiter */ toJSON() { return { store: this.#store, ...this.#options }; } /** * Throttle request using the pre-defined options. Returns * LimiterResponse when request is allowed or throws * an exception. */ async throttle(prefix, ctx) { if (!this.#options.requests || !this.#options.duration) { throw new RuntimeException( `Cannot throttle requests for "${prefix}" limiter. Make sure to define the allowed requests and duration` ); } const limiter = this.#store ? this.#manager.use(this.#store, this.#options) : this.#manager.use(this.#options); const key = `${prefix}_${this.#key || `ip_${ctx.request.ip()}`}`; debug_default('throttling HTTP request for key "%s"', key); const limiterResponse = await limiter.get(key); if (limiterResponse && limiterResponse.consumed > limiterResponse.limit) { debug_default('requests exhausted for key "%s"', key); const error = new E_TOO_MANY_REQUESTS(limiterResponse); this.#exceptionModifier(error); throw error; } try { const consumeResponse = await limiter.consume(key); return consumeResponse; } catch (error) { if (error instanceof E_TOO_MANY_REQUESTS) { debug_default('requests exhausted for key "%s"', key); this.#exceptionModifier(error); } throw error; } } }; // src/limiter_manager.ts import string2 from "@adonisjs/core/helpers/string"; import { RuntimeException as RuntimeException2 } from "@adonisjs/core/exceptions"; var LimiterManager = class { constructor(config) { this.config = config; this.config = config; } /** * Cached limiters. One limiter is created for a unique combination * of "store,requests,duration,blockDuration" options */ #limiters = /* @__PURE__ */ new Map(); /** * Creates a unique key for a limiter instance. Since, we allow creating * limiters with runtime options for "requests", "duration" and "blockDuration". * The limiterKey is used to identify a limiter instance. */ makeLimiterKey(store, options) { const chunks = [`s:${String(store)}`, `r:${options.requests}`, `d:${options.duration}`]; if (options.blockDuration) { chunks.push(`bd:${options.blockDuration}`); } if (options.inMemoryBlockOnConsumed) { chunks.push(`mbc:${options.inMemoryBlockOnConsumed}`); } if (options.inMemoryBlockDuration) { chunks.push(`mbd:${options.inMemoryBlockDuration}`); } return chunks.join(","); } use(store, options) { let storeToUse = typeof store === "string" ? store : this.config.default; let optionsToUse = typeof store === "object" ? store : options; if (!optionsToUse) { throw new RuntimeException2( "Specify the number of allowed requests and duration to create a limiter" ); } optionsToUse.duration = string2.seconds.parse(optionsToUse.duration); if (optionsToUse.blockDuration) { optionsToUse.blockDuration = string2.seconds.parse(optionsToUse.blockDuration); } if (optionsToUse.inMemoryBlockDuration) { optionsToUse.inMemoryBlockDuration = string2.seconds.parse(optionsToUse.inMemoryBlockDuration); } if (!this.#limiters.has(storeToUse)) { this.#limiters.set(storeToUse, /* @__PURE__ */ new Map()); } const storeLimiters = this.#limiters.get(storeToUse); const limiterKey = this.makeLimiterKey(storeToUse, optionsToUse); debug_default('created limiter key "%s"', limiterKey); if (storeLimiters.has(limiterKey)) { debug_default('re-using cached limiter store "%s", options %O', storeToUse, optionsToUse); return storeLimiters.get(limiterKey); } const limiter = new Limiter(this.config.stores[storeToUse](optionsToUse)); debug_default('creating new limiter instance "%s", options %O', storeToUse, optionsToUse); storeLimiters.set(limiterKey, limiter); return limiter; } /** * Clear stored data with the stores */ async clear(stores2) { const storesToUse = stores2 || Object.keys(this.config.stores); for (let store of storesToUse) { const storeLimiters = this.#limiters.get(store); if (storeLimiters) { if (store === "memory") { for (let limiter of storeLimiters.values()) { await limiter.clear(); } } else { const [limiter] = storeLimiters.values(); limiter && await limiter.clear(); } } } } /** * Creates HTTP limiter instance */ allowRequests(requests) { return new HttpLimiter(this).allowRequests(requests); } /** * A shorthand method that returns null to disable * rate limiting */ noLimit() { return null; } /** * Define a named HTTP middleware to apply rate * limits on specific routes */ define(name, builder) { const middlewareFn = async (ctx, next) => { const limiter = await builder(ctx); if (!limiter) { return next(); } const limiterResponse = await limiter.throttle(name, ctx); const response = await next(); ctx.response.header("X-RateLimit-Limit", limiterResponse.limit); ctx.response.header("X-RateLimit-Remaining", limiterResponse.remaining); return response; }; Object.defineProperty(middlewareFn, "name", { value: `${name}Throttle` }); return middlewareFn; } }; // src/define_config.ts import { configProvider } from "@adonisjs/core"; import { InvalidArgumentsException, RuntimeException as RuntimeException3 } from "@adonisjs/core/exceptions"; function defineConfig(config) { if (!config.stores) { throw new InvalidArgumentsException('Missing "stores" property in limiter config'); } if (!config.default) { throw new InvalidArgumentsException(`Missing "default" store in limiter config`); } if (!config.stores[config.default]) { throw new InvalidArgumentsException( `Missing "stores.${String( config.default )}" in limiter config. It is referenced by the "default" property` ); } return configProvider.create(async (app) => { debug_default("resolving limiter config"); const storesList = Object.keys(config.stores); const stores2 = {}; for (let storeName of storesList) { const store = config.stores[storeName]; if (typeof store === "function") { stores2[storeName] = store; } else { stores2[storeName] = await store.resolver(app); } } return { default: config.default, stores: stores2 }; }); } var stores = { redis: (config) => { return configProvider.create(async (app) => { const redis = await app.container.make("redis"); const { default: LimiterRedisStore } = await import("./src/stores/redis.js"); return (consumptionOptions) => new LimiterRedisStore(redis.connection(config.connectionName), { ...config, ...consumptionOptions }); }); }, database: (config) => { return configProvider.create(async (app) => { const db = await app.container.make("lucid.db"); const { default: LimiterDatabaseStore } = await import("./src/stores/database.js"); config.connectionName = config.connectionName || db.primaryConnectionName; const connection = db.manager.get(config.connectionName); if (!connection) { throw new RuntimeException3( `Invalid connection name "${config.connectionName}" referenced by "config/limiter.ts" file. First register the connection inside "config/database.ts" file` ); } if (!config.dbName && connection.config.connection && typeof connection.config.connection !== "string" && "database" in connection.config.connection) { config.dbName = connection.config.connection.database; } return (consumptionOptions) => new LimiterDatabaseStore(db.connection(config.connectionName), { ...config, ...consumptionOptions }); }); }, memory: (config) => { return (consumptionOptions) => new LimiterMemoryStore({ ...config, ...consumptionOptions }); } }; export { stubsRoot, configure, Limiter, HttpLimiter, LimiterManager, defineConfig, stores };