/* * This file is part of a proprietary work. * * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. * You may not use this file except in compliance with the License. * Unauthorized use, copying, modification, or distribution is strictly prohibited. * * This file is not licensed under the AGPLv3. */ import { Store, Options, IncrementResponse } from 'express-rate-limit'; import { rateLimitService } from './rateLimit'; import logger from '@server/logger'; /** * A Redis-backed rate limiting store for express-rate-limit that optimizes * for local read performance and batched writes to Redis. * * This store uses the same optimized rate limiting logic as the WebSocket * implementation, providing: * - Local caching for fast reads * - Batched writes to Redis to reduce load * - Automatic cleanup of expired entries * - Graceful fallback when Redis is unavailable */ export default class RedisStore implements Store { /** * The duration of time before which all hit counts are reset (in milliseconds). */ windowMs!: number; /** * Maximum number of requests allowed within the window. */ max!: number; /** * Optional prefix for Redis keys to avoid collisions. */ prefix: string; /** * Whether to skip incrementing on failed requests. */ skipFailedRequests: boolean; /** * Whether to skip incrementing on successful requests. */ skipSuccessfulRequests: boolean; /** * @constructor for RedisStore. * * @param options - Configuration options for the store. */ constructor(options: { prefix?: string; skipFailedRequests?: boolean; skipSuccessfulRequests?: boolean; } = {}) { this.prefix = options.prefix || 'express-rate-limit'; this.skipFailedRequests = options.skipFailedRequests || false; this.skipSuccessfulRequests = options.skipSuccessfulRequests || false; } /** * Method that actually initializes the store. Must be synchronous. * * @param options - The options used to setup express-rate-limit. */ init(options: Options): void { this.windowMs = options.windowMs; this.max = options.max as number; // logger.debug(`RedisStore initialized with windowMs: ${this.windowMs}, max: ${this.max}, prefix: ${this.prefix}`); } /** * Method to increment a client's hit counter. * * @param key - The identifier for a client (usually IP address). * @returns Promise resolving to the number of hits and reset time for that client. */ async increment(key: string): Promise { try { const clientId = `${this.prefix}:${key}`; const result = await rateLimitService.checkRateLimit( clientId, undefined, // No message type for HTTP requests this.max, undefined, // No message type limit this.windowMs ); // logger.debug(`Incremented rate limit for key: ${key} with max: ${this.max}, totalHits: ${result.totalHits}`); return { totalHits: result.totalHits || 1, resetTime: result.resetTime || new Date(Date.now() + this.windowMs) }; } catch (error) { logger.error(`RedisStore increment error for key ${key}:`, error); // Return safe defaults on error to prevent blocking requests return { totalHits: 1, resetTime: new Date(Date.now() + this.windowMs) }; } } /** * Method to decrement a client's hit counter. * Used when skipSuccessfulRequests or skipFailedRequests is enabled. * * @param key - The identifier for a client. */ async decrement(key: string): Promise { try { const clientId = `${this.prefix}:${key}`; await rateLimitService.decrementRateLimit(clientId); // logger.debug(`Decremented rate limit for key: ${key}`); } catch (error) { logger.error(`RedisStore decrement error for key ${key}:`, error); // Don't throw - decrement failures shouldn't block requests } } /** * Method to reset a client's hit counter. * * @param key - The identifier for a client. */ async resetKey(key: string): Promise { try { const clientId = `${this.prefix}:${key}`; await rateLimitService.resetKey(clientId); // logger.debug(`Reset rate limit for key: ${key}`); } catch (error) { logger.error(`RedisStore resetKey error for key ${key}:`, error); // Don't throw - reset failures shouldn't block requests } } /** * Method to reset everyone's hit counter. * * This method is optional and is never called by express-rate-limit. * We implement it for completeness but it's not recommended for production use * as it could be expensive with large datasets. */ async resetAll(): Promise { try { logger.warn('RedisStore resetAll called - this operation can be expensive'); // Force sync all pending data first await rateLimitService.forceSyncAllPendingData(); // Note: We don't actually implement full reset as it would require // scanning all Redis keys with our prefix, which could be expensive. // In production, it's better to let entries expire naturally. logger.info('RedisStore resetAll completed (pending data synced)'); } catch (error) { logger.error('RedisStore resetAll error:', error); // Don't throw - this is an optional method } } /** * Get current hit count for a key without incrementing. * This is a custom method not part of the Store interface. * * @param key - The identifier for a client. * @returns Current hit count and reset time, or null if no data exists. */ async getHits(key: string): Promise<{ totalHits: number; resetTime: Date } | null> { try { const clientId = `${this.prefix}:${key}`; // Use checkRateLimit with max + 1 to avoid actually incrementing // but still get the current count const result = await rateLimitService.checkRateLimit( clientId, undefined, this.max + 1000, // Set artificially high to avoid triggering limit undefined, this.windowMs ); // Decrement since we don't actually want to count this check await rateLimitService.decrementRateLimit(clientId); return { totalHits: Math.max(0, (result.totalHits || 0) - 1), // Adjust for the decrement resetTime: result.resetTime || new Date(Date.now() + this.windowMs) }; } catch (error) { logger.error(`RedisStore getHits error for key ${key}:`, error); return null; } } /** * Cleanup method for graceful shutdown. * This is not part of the Store interface but is useful for cleanup. */ async shutdown(): Promise { try { // The rateLimitService handles its own cleanup logger.info('RedisStore shutdown completed'); } catch (error) { logger.error('RedisStore shutdown error:', error); } } }