import { Request, Response } from 'express'; import { DyFM_Error } from '@futdevpro/fsm-dynamo'; import { DyNTS_SingletonServiceBase } from '../../_services/base/singleton.service-base'; import { DyNTS_global_settings } from '../../_collections/global-settings.const'; import { DyNTS_RateLimit_Config } from './_models/rate-limit-config.interface'; import { DyNTS_RateLimit_Policy } from './_models/rate-limit-policy.interface'; /** Default request-limit per default-window. */ const DEFAULT_LIMIT: number = 100; /** Default sliding-window hossza ms-ben (1 perc). */ const DEFAULT_WINDOW_MS: number = 60000; /** Default response-header allitas. */ const DEFAULT_RESPONSE_HEADERS: boolean = true; /** Periodikus garbage-collection intervallum ms-ben (5 perc). * A request-log-bol takaritja a regen inaktiv kulcsokat (memory-leak prevention). */ const GC_INTERVAL_MS: number = 5 * 60 * 1000; /** Service-nev az error-okhoz. */ const SERVICE_NAME: string = 'DyNTS_RateLimit_Middleware'; /** ErrorCode-builder — system shortcode + sajat kod. */ const buildErrorCode = (subcode: string): string => { const sys: string = DyNTS_global_settings.systemShortCodeName ?? 'DyNTS'; return `${sys}|DyNTS-RL-${subcode}`; }; /** * Sliding-window HTTP rate-limit middleware — opt-in service-szel, a meglevo * `DyNTS_Endpoint_Params.preProcesses` mechanizmus mellol mukodik. * * **Hasznalat (host app):** * ```ts * const rateLimit = DyNTS_RateLimit_Middleware.getInstance(); * rateLimit.configure({ * defaultLimit: 100, // 100 req/perc default * defaultWindowMs: 60_000, * keyExtractor: (req) => req.headers['x-api-key'] as string || req.ip, * }); * * new DyNTS_Endpoint_Params({ * ..., * preProcesses: [rateLimit.check, ...other], * }); * * // subscription-tier-up: per-kulcs egyedi limit * rateLimit.setPolicyForKey('subscriber-tier-key-123', { * limit: 1000, * windowMs: 60_000, * }); * ``` * * **Viselkedes:** * - Sliding-window algoritmus: minden request egy timestamp; a window-on * kivuli timestamp-ek nem szamolnak. Tobb pontos mint a fix-bucket * (boundary-burst nincs). * - In-memory storage — single-instance MVP-nek megfelelo. Multi-instance * prod-hoz Redis-backed extension kell (lasd a kozelebb dokumentumaltot). * - Limit lepes: 429 DyFM_Error + `X-RateLimit-*` + `Retry-After` header-ek. * * **Storage:** `Map` ahol storageKey = `${subject}|${endpoint}`. * Periodikus GC takaritja a inaktiv kulcsokat. * * **Singleton:** `getInstance()`-szel hivd. A `.check` mezo binding-elve van * `this`-re, igy direkt atadhato `preProcesses`-be ujracsomagolas nelkul. */ export class DyNTS_RateLimit_Middleware extends DyNTS_SingletonServiceBase { static getInstance(): DyNTS_RateLimit_Middleware { return DyNTS_RateLimit_Middleware.getSingletonInstance() as DyNTS_RateLimit_Middleware; } private defaultLimit: number = DEFAULT_LIMIT; private defaultWindowMs: number = DEFAULT_WINDOW_MS; private responseHeaders: boolean = DEFAULT_RESPONSE_HEADERS; private keyExtractor: (req: Request) => string = (req: Request): string => this.defaultKeyExtractor(req); private endpointGrouper: (req: Request) => string = (req: Request): string => req.path; /** request-log: storageKey → timestamp-tomb (Date.now() ms). */ private requestLog: Map = new Map(); /** Per-kulcs egyedi policy-k. */ private keyPolicies: Map = new Map(); /** Per-endpoint(-csoport) egyedi policy-k (a `endpointGrouper` outputjara kulcsolva). */ private endpointPolicies: Map = new Map(); /** GC timer handle. */ private gcTimer: NodeJS.Timeout | null = null; /** * Konfig override. Hianyzo mezok a default-okat orzik. Hivhato barmikor — * a `check()` a friss config-ot olvassa. */ configure(config: DyNTS_RateLimit_Config): void { if (config.defaultLimit !== undefined) { this.defaultLimit = config.defaultLimit; } if (config.defaultWindowMs !== undefined) { this.defaultWindowMs = config.defaultWindowMs; } if (config.keyExtractor !== undefined) { this.keyExtractor = config.keyExtractor; } if (config.endpointGrouper !== undefined) { this.endpointGrouper = config.endpointGrouper; } if (config.responseHeaders !== undefined) { this.responseHeaders = config.responseHeaders; } if (config.initialKeyPolicies !== undefined) { for (const [ key, policy ] of Object.entries(config.initialKeyPolicies)) { this.keyPolicies.set(key, policy); } } if (config.initialEndpointPolicies !== undefined) { for (const [ endpoint, policy ] of Object.entries(config.initialEndpointPolicies)) { this.endpointPolicies.set(endpoint, policy); } } this.startGcTimer(); } /** * Aktualis konfig olvasasa (diagnosztika celokra). */ getConfig(): { defaultLimit: number; defaultWindowMs: number; responseHeaders: boolean; activeKeyPolicies: number; activeEndpointPolicies: number; trackedStorageKeys: number; } { return { defaultLimit: this.defaultLimit, defaultWindowMs: this.defaultWindowMs, responseHeaders: this.responseHeaders, activeKeyPolicies: this.keyPolicies.size, activeEndpointPolicies: this.endpointPolicies.size, trackedStorageKeys: this.requestLog.size, }; } /** * Per-kulcs egyedi policy beallitas (pl. subscription-tier alapjan). * A `key`-nek pontosan azzal a stringgel kell egyeznie, amit a `keyExtractor` * visszaad. */ setPolicyForKey(key: string, policy: DyNTS_RateLimit_Policy): void { this.keyPolicies.set(key, policy); } /** * Per-kulcs policy torlese (visszaall a default-ra). */ clearPolicyForKey(key: string): void { this.keyPolicies.delete(key); } /** * Per-endpoint(-csoport) egyedi policy beallitas. Az `endpoint`-nek pontosan * azzal a stringgel kell egyeznie, amit az `endpointGrouper` visszaad (default: `req.path`). * Akkor hasznos, ha egy endpointnak a globalis default-tol eltero limit kell — pl. egy * webhook nagy burst-toleranciat igenyel (a legit, distributed forgalom ne bukjon), mig * egy admin-endpoint szuk limitet. Precedencia: per-kulcs policy > per-endpoint policy > default. */ setPolicyForEndpoint(endpoint: string, policy: DyNTS_RateLimit_Policy): void { this.endpointPolicies.set(endpoint, policy); } /** * Per-endpoint policy torlese (visszaall a default-ra). */ clearPolicyForEndpoint(endpoint: string): void { this.endpointPolicies.delete(endpoint); } /** * Pre-process function — atadhato `DyNTS_Endpoint_Params.preProcesses`-be. * * Throws: * - 429 ha az aktualis request meghaladna a limit-et a sliding window-on * * Side-effect: ha `responseHeaders === true`, beallitja az `X-RateLimit-Limit`, * `X-RateLimit-Remaining`, `X-RateLimit-Reset` header-eket; 429 eseten * a `Retry-After` header-t is. */ readonly check = async (req: Request, res: Response): Promise => { const subject: string = this.keyExtractor(req); const endpoint: string = this.endpointGrouper(req); const storageKey: string = `${subject}|${endpoint}`; // precedencia: per-kulcs policy (legspecifikusabb, pl. subscriber-tier) > per-endpoint policy > default const policy: DyNTS_RateLimit_Policy = this.keyPolicies.get(subject) ?? this.endpointPolicies.get(endpoint) ?? { limit: this.defaultLimit, windowMs: this.defaultWindowMs, }; const now: number = Date.now(); const windowStart: number = now - policy.windowMs; // sliding-window: tartomanyon kivuli timestamp-eket eldobjuk const existing: number[] = this.requestLog.get(storageKey) ?? []; const recent: number[] = existing.filter((t: number): boolean => t > windowStart); if (recent.length >= policy.limit) { const oldest: number = recent[0]; const resetAt: number = oldest + policy.windowMs; const retryAfterSec: number = Math.max(1, Math.ceil((resetAt - now) / 1000)); if (this.responseHeaders) { res.setHeader('X-RateLimit-Limit', policy.limit.toString()); res.setHeader('X-RateLimit-Remaining', '0'); res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000).toString()); res.setHeader('Retry-After', retryAfterSec.toString()); } // Frissitjuk a log-ot a kiszurt verzioval (felesleges regi timestamp-eket eldobtuk) this.requestLog.set(storageKey, recent); throw new DyFM_Error({ status: 429, errorCode: buildErrorCode('LIMIT'), addECToUserMsg: true, message: `Rate limit exceeded: ${policy.limit} req per ${policy.windowMs}ms for ${storageKey}`, userMessage: `Too many requests, retry after ${retryAfterSec}s`, issuerService: SERVICE_NAME, }); } // alatta vagyunk a limit-nek: append the current request recent.push(now); this.requestLog.set(storageKey, recent); if (this.responseHeaders) { res.setHeader('X-RateLimit-Limit', policy.limit.toString()); res.setHeader('X-RateLimit-Remaining', Math.max(0, policy.limit - recent.length).toString()); res.setHeader('X-RateLimit-Reset', Math.ceil((now + policy.windowMs) / 1000).toString()); } }; /** * Default key-extractor: x-forwarded-for vagy req.ip vagy 'unknown'. * Csak akkor hasznalt, ha a host nem allit be sajat extractort a configure-ben. */ private defaultKeyExtractor(req: Request): string { const xff: unknown = req.headers['x-forwarded-for']; if (typeof xff === 'string' && xff.length > 0) { const first: string = xff.split(',')[0]?.trim() ?? ''; if (first.length > 0) { return first; } } if (Array.isArray(xff) && xff.length > 0) { const first: string = xff[0]?.split(',')[0]?.trim() ?? ''; if (first.length > 0) { return first; } } return req.ip ?? 'unknown'; } /** * GC timer inditasa (idempotent). Periodikusan eltavolitja az inaktiv * storage-key-eket a request-log-bol — memory-leak prevention. */ private startGcTimer(): void { if (this.gcTimer !== null) { return; } this.gcTimer = setInterval((): void => { this.runGc(); }, GC_INTERVAL_MS); // unref hogy a process ne maradjon eletben a timer miatt if (typeof this.gcTimer.unref === 'function') { this.gcTimer.unref(); } } /** * GC sweep — minden storage-key-rol levagja a regi timestamp-eket, es * eltavolitja az ureseket. Hivhato kulonosen test-bol. */ runGc(): void { const now: number = Date.now(); const horizon: number = now - this.defaultWindowMs; for (const [ key, timestamps ] of this.requestLog) { const recent: number[] = timestamps.filter((t: number): boolean => t > horizon); if (recent.length === 0) { this.requestLog.delete(key); } else { this.requestLog.set(key, recent); } } } /** * GC timer leallitasa (graceful shutdown vagy test-cleanup). */ stopGcTimer(): void { if (this.gcTimer !== null) { clearInterval(this.gcTimer); this.gcTimer = null; } } /** * Test-only: visszaallitja a default config-ot + uriti a state-et, hogy a * specfajlok ne szivarogjak at egymas state-jet. Production code NE hivja. */ _resetForTesting(): void { this.defaultLimit = DEFAULT_LIMIT; this.defaultWindowMs = DEFAULT_WINDOW_MS; this.responseHeaders = DEFAULT_RESPONSE_HEADERS; this.keyExtractor = (req: Request): string => this.defaultKeyExtractor(req); this.endpointGrouper = (req: Request): string => req.path; this.requestLog.clear(); this.keyPolicies.clear(); this.endpointPolicies.clear(); this.stopGcTimer(); } }