Auto-commit 2026-05-02 09:37

This commit is contained in:
2026-05-02 09:37:30 -04:00
parent b6b0f86d73
commit fe754761d9
15 changed files with 674 additions and 14 deletions

View 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);
}