mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
205 lines
5.3 KiB
TypeScript
205 lines
5.3 KiB
TypeScript
/**
|
|
* Submission Rate Limiter
|
|
*
|
|
* Client-side rate limiting for submission creation to prevent
|
|
* abuse and accidental duplicate submissions.
|
|
*
|
|
* Part of Sacred Pipeline Phase 3: Enhanced Error Handling
|
|
*/
|
|
|
|
import { logger } from './logger';
|
|
|
|
interface RateLimitConfig {
|
|
maxSubmissionsPerMinute: number;
|
|
maxSubmissionsPerHour: number;
|
|
cooldownAfterLimit: number; // milliseconds
|
|
}
|
|
|
|
interface RateLimitRecord {
|
|
timestamps: number[];
|
|
lastAttempt: number;
|
|
blockedUntil?: number;
|
|
}
|
|
|
|
const DEFAULT_CONFIG: RateLimitConfig = {
|
|
maxSubmissionsPerMinute: 5,
|
|
maxSubmissionsPerHour: 20,
|
|
cooldownAfterLimit: 60000, // 1 minute
|
|
};
|
|
|
|
// Store rate limit data in memory (per session)
|
|
const rateLimitStore = new Map<string, RateLimitRecord>();
|
|
|
|
/**
|
|
* Clean up old timestamps from rate limit record
|
|
*/
|
|
function cleanupTimestamps(record: RateLimitRecord, now: number): void {
|
|
const oneHourAgo = now - 60 * 60 * 1000;
|
|
record.timestamps = record.timestamps.filter(ts => ts > oneHourAgo);
|
|
}
|
|
|
|
/**
|
|
* Get or create rate limit record for user
|
|
*/
|
|
function getRateLimitRecord(userId: string): RateLimitRecord {
|
|
if (!rateLimitStore.has(userId)) {
|
|
rateLimitStore.set(userId, {
|
|
timestamps: [],
|
|
lastAttempt: 0,
|
|
});
|
|
}
|
|
return rateLimitStore.get(userId)!;
|
|
}
|
|
|
|
/**
|
|
* Check if user can submit based on rate limits
|
|
*
|
|
* @param userId - User ID to check
|
|
* @param config - Optional rate limit configuration
|
|
* @returns Object indicating if allowed and retry information
|
|
*/
|
|
export function checkSubmissionRateLimit(
|
|
userId: string,
|
|
config: Partial<RateLimitConfig> = {}
|
|
): {
|
|
allowed: boolean;
|
|
reason?: string;
|
|
retryAfter?: number; // seconds
|
|
remaining?: number;
|
|
} {
|
|
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
const now = Date.now();
|
|
const record = getRateLimitRecord(userId);
|
|
|
|
// Clean up old timestamps
|
|
cleanupTimestamps(record, now);
|
|
|
|
// Check if user is currently blocked
|
|
if (record.blockedUntil && now < record.blockedUntil) {
|
|
const retryAfter = Math.ceil((record.blockedUntil - now) / 1000);
|
|
|
|
logger.warn('[SubmissionRateLimiter] User blocked', {
|
|
userId,
|
|
retryAfter,
|
|
});
|
|
|
|
return {
|
|
allowed: false,
|
|
reason: `Rate limit exceeded. Please wait ${retryAfter} seconds before submitting again`,
|
|
retryAfter,
|
|
};
|
|
}
|
|
|
|
// Check per-minute limit
|
|
const oneMinuteAgo = now - 60 * 1000;
|
|
const submissionsLastMinute = record.timestamps.filter(ts => ts > oneMinuteAgo).length;
|
|
|
|
if (submissionsLastMinute >= cfg.maxSubmissionsPerMinute) {
|
|
record.blockedUntil = now + cfg.cooldownAfterLimit;
|
|
const retryAfter = Math.ceil(cfg.cooldownAfterLimit / 1000);
|
|
|
|
logger.warn('[SubmissionRateLimiter] Per-minute limit exceeded', {
|
|
userId,
|
|
submissionsLastMinute,
|
|
limit: cfg.maxSubmissionsPerMinute,
|
|
retryAfter,
|
|
});
|
|
|
|
return {
|
|
allowed: false,
|
|
reason: `Too many submissions in a short time. Please wait ${retryAfter} seconds`,
|
|
retryAfter,
|
|
};
|
|
}
|
|
|
|
// Check per-hour limit
|
|
const submissionsLastHour = record.timestamps.length;
|
|
|
|
if (submissionsLastHour >= cfg.maxSubmissionsPerHour) {
|
|
record.blockedUntil = now + cfg.cooldownAfterLimit;
|
|
const retryAfter = Math.ceil(cfg.cooldownAfterLimit / 1000);
|
|
|
|
logger.warn('[SubmissionRateLimiter] Per-hour limit exceeded', {
|
|
userId,
|
|
submissionsLastHour,
|
|
limit: cfg.maxSubmissionsPerHour,
|
|
retryAfter,
|
|
});
|
|
|
|
return {
|
|
allowed: false,
|
|
reason: `Hourly submission limit reached. Please wait ${retryAfter} seconds`,
|
|
retryAfter,
|
|
};
|
|
}
|
|
|
|
// Calculate remaining submissions
|
|
const remainingMinute = cfg.maxSubmissionsPerMinute - submissionsLastMinute;
|
|
const remainingHour = cfg.maxSubmissionsPerHour - submissionsLastHour;
|
|
const remaining = Math.min(remainingMinute, remainingHour);
|
|
|
|
return {
|
|
allowed: true,
|
|
remaining,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Record a submission attempt
|
|
*
|
|
* @param userId - User ID
|
|
*/
|
|
export function recordSubmissionAttempt(userId: string): void {
|
|
const now = Date.now();
|
|
const record = getRateLimitRecord(userId);
|
|
|
|
record.timestamps.push(now);
|
|
record.lastAttempt = now;
|
|
|
|
// Clean up immediately to maintain accurate counts
|
|
cleanupTimestamps(record, now);
|
|
|
|
logger.info('[SubmissionRateLimiter] Recorded submission', {
|
|
userId,
|
|
totalLastHour: record.timestamps.length,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear rate limit for user (useful for testing or admin override)
|
|
*
|
|
* @param userId - User ID to clear
|
|
*/
|
|
export function clearUserRateLimit(userId: string): void {
|
|
rateLimitStore.delete(userId);
|
|
logger.info('[SubmissionRateLimiter] Cleared rate limit', { userId });
|
|
}
|
|
|
|
/**
|
|
* Get current rate limit status for user
|
|
*
|
|
* @param userId - User ID
|
|
* @returns Current status information
|
|
*/
|
|
export function getRateLimitStatus(userId: string): {
|
|
submissionsLastMinute: number;
|
|
submissionsLastHour: number;
|
|
isBlocked: boolean;
|
|
blockedUntil?: Date;
|
|
} {
|
|
const now = Date.now();
|
|
const record = getRateLimitRecord(userId);
|
|
|
|
cleanupTimestamps(record, now);
|
|
|
|
const oneMinuteAgo = now - 60 * 1000;
|
|
const submissionsLastMinute = record.timestamps.filter(ts => ts > oneMinuteAgo).length;
|
|
|
|
return {
|
|
submissionsLastMinute,
|
|
submissionsLastHour: record.timestamps.length,
|
|
isBlocked: !!(record.blockedUntil && now < record.blockedUntil),
|
|
blockedUntil: record.blockedUntil ? new Date(record.blockedUntil) : undefined,
|
|
};
|
|
}
|