mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-16 13:06:27 +00:00
224 lines
7.5 KiB
TypeScript
224 lines
7.5 KiB
TypeScript
/*
|
|
* 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<IncrementResponse> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
try {
|
|
// The rateLimitService handles its own cleanup
|
|
logger.info('RedisStore shutdown completed');
|
|
} catch (error) {
|
|
logger.error('RedisStore shutdown error:', error);
|
|
}
|
|
}
|
|
}
|