Auto-commit 2026-05-02 09:37
This commit is contained in:
177
services/spamshield/src/middleware/spam-rate-limit.middleware.ts
Normal file
177
services/spamshield/src/middleware/spam-rate-limit.middleware.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { RedisService } from '@shieldai/shared-notifications';
|
||||
import { TierRateLimits, SubscriptionTier, spamRateLimits } from '../config/spamshield.config';
|
||||
|
||||
export interface RateLimitStatus {
|
||||
exceeded: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
export interface RateLimitOptions {
|
||||
windowMs?: number;
|
||||
dailyWindowMs?: number;
|
||||
}
|
||||
|
||||
export class SpamRateLimitMiddleware {
|
||||
private redisService: RedisService;
|
||||
private options: RateLimitOptions;
|
||||
|
||||
constructor(redisService: RedisService, options?: RateLimitOptions) {
|
||||
this.redisService = redisService;
|
||||
this.options = {
|
||||
windowMs: options?.windowMs || 60000,
|
||||
dailyWindowMs: options?.dailyWindowMs || 86400000,
|
||||
};
|
||||
}
|
||||
|
||||
private getMinuteKey(userId: string, tier: SubscriptionTier): string {
|
||||
const windowMs = this.options.windowMs ?? 60000;
|
||||
const minuteTimestamp = Math.floor(Date.now() / windowMs);
|
||||
return `ratelimit:spam:${userId}:${tier}:min:${minuteTimestamp}`;
|
||||
}
|
||||
|
||||
private getDayKey(userId: string, tier: SubscriptionTier): string {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
return `ratelimit:spam:${userId}:${tier}:day:${date}`;
|
||||
}
|
||||
|
||||
private getResetTime(windowMs: number): Date {
|
||||
const now = Date.now();
|
||||
const resetTimestamp = Math.ceil(now / windowMs) * windowMs;
|
||||
return new Date(resetTimestamp);
|
||||
}
|
||||
|
||||
async checkLimit(
|
||||
userId: string,
|
||||
tier: SubscriptionTier,
|
||||
): Promise<RateLimitStatus> {
|
||||
const tierLimits = spamRateLimits[tier];
|
||||
const minuteKey = this.getMinuteKey(userId, tier);
|
||||
const dayKey = this.getDayKey(userId, tier);
|
||||
|
||||
try {
|
||||
const [minuteCount, dayCount] = await Promise.all([
|
||||
this.redisService.getCounter(minuteKey),
|
||||
this.redisService.getCounter(dayKey),
|
||||
]);
|
||||
|
||||
const minuteExceeded = minuteCount >= tierLimits.perMinute;
|
||||
const dayExceeded = dayCount >= tierLimits.perDay;
|
||||
const exceeded = minuteExceeded || dayExceeded;
|
||||
|
||||
const effectiveLimit = exceeded
|
||||
? Math.min(tierLimits.perMinute, tierLimits.perDay)
|
||||
: Math.min(tierLimits.perMinute, tierLimits.perDay);
|
||||
|
||||
const effectiveCount = exceeded
|
||||
? Math.min(minuteCount, dayCount)
|
||||
: Math.min(minuteCount, dayCount);
|
||||
|
||||
const windowMs = this.options.windowMs ?? 60000;
|
||||
return {
|
||||
exceeded,
|
||||
limit: effectiveLimit,
|
||||
remaining: Math.max(0, effectiveLimit - effectiveCount),
|
||||
resetAt: this.getResetTime(windowMs),
|
||||
retryAfterSeconds: Math.ceil(
|
||||
(this.getResetTime(windowMs).getTime() - Date.now()) / 1000,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[SpamRateLimit] Redis error:', error);
|
||||
const windowMs = this.options.windowMs ?? 60000;
|
||||
return {
|
||||
exceeded: false,
|
||||
limit: tierLimits.perMinute,
|
||||
remaining: tierLimits.perMinute,
|
||||
resetAt: this.getResetTime(windowMs),
|
||||
retryAfterSeconds: windowMs / 1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async incrementCounter(
|
||||
userId: string,
|
||||
tier: SubscriptionTier,
|
||||
): Promise<{ minuteCount: number; dayCount: number }> {
|
||||
const minuteKey = this.getMinuteKey(userId, tier);
|
||||
const dayKey = this.getDayKey(userId, tier);
|
||||
|
||||
try {
|
||||
const windowMs = this.options.windowMs ?? 60000;
|
||||
const dailyWindowMs = this.options.dailyWindowMs ?? 86400000;
|
||||
const [minuteCount, dayCount] = await Promise.all([
|
||||
this.redisService.increment(minuteKey, Math.ceil(windowMs / 1000)),
|
||||
this.redisService.increment(dayKey, Math.ceil(dailyWindowMs / 1000)),
|
||||
]);
|
||||
|
||||
return { minuteCount, dayCount };
|
||||
} catch (error) {
|
||||
console.error('[SpamRateLimit] Increment error:', error);
|
||||
return { minuteCount: 0, dayCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async checkAndIncrement(
|
||||
userId: string,
|
||||
tier: SubscriptionTier,
|
||||
): Promise<{ allowed: boolean; status: RateLimitStatus }> {
|
||||
const status = await this.checkLimit(userId, tier);
|
||||
|
||||
if (!status.exceeded) {
|
||||
await this.incrementCounter(userId, tier);
|
||||
const updatedStatus = await this.checkLimit(userId, tier);
|
||||
return { allowed: true, status: updatedStatus };
|
||||
}
|
||||
|
||||
return { allowed: false, status };
|
||||
}
|
||||
|
||||
async getUsage(userId: string, tier: SubscriptionTier): Promise<{
|
||||
minuteUsed: number;
|
||||
minuteLimit: number;
|
||||
minuteRemaining: number;
|
||||
dayUsed: number;
|
||||
dayLimit: number;
|
||||
dayRemaining: number;
|
||||
}> {
|
||||
const tierLimits = spamRateLimits[tier];
|
||||
const minuteKey = this.getMinuteKey(userId, tier);
|
||||
const dayKey = this.getDayKey(userId, tier);
|
||||
|
||||
try {
|
||||
const [minuteCount, dayCount] = await Promise.all([
|
||||
this.redisService.getCounter(minuteKey),
|
||||
this.redisService.getCounter(dayKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
minuteUsed: minuteCount,
|
||||
minuteLimit: tierLimits.perMinute,
|
||||
minuteRemaining: Math.max(0, tierLimits.perMinute - minuteCount),
|
||||
dayUsed: dayCount,
|
||||
dayLimit: tierLimits.perDay,
|
||||
dayRemaining: Math.max(0, tierLimits.perDay - dayCount),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[SpamRateLimit] Usage fetch error:', error);
|
||||
return {
|
||||
minuteUsed: 0,
|
||||
minuteLimit: tierLimits.perMinute,
|
||||
minuteRemaining: tierLimits.perMinute,
|
||||
dayUsed: 0,
|
||||
dayLimit: tierLimits.perDay,
|
||||
dayRemaining: tierLimits.perDay,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSpamRateLimitMiddleware(
|
||||
redisService: RedisService,
|
||||
options?: RateLimitOptions,
|
||||
): SpamRateLimitMiddleware {
|
||||
return new SpamRateLimitMiddleware(redisService, options);
|
||||
}
|
||||
Reference in New Issue
Block a user