File size: 4,858 Bytes
391c43e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
/**
 * Rate Limiter for Analytics Endpoints
 *
 * In-memory sliding window rate limiter to prevent abuse.
 * Uses LRU-like cleanup to prevent memory leaks.
 */

interface RateLimitConfig {
  limit: number;      // Max requests per window
  windowMs: number;   // Time window in milliseconds
}

class RateLimiter {
  private requests = new Map<string, number[]>();
  private lastCleanup = Date.now();
  private readonly CLEANUP_INTERVAL = 60 * 1000; // Clean up every minute
  private readonly MAX_KEYS = 10000; // Prevent memory exhaustion

  /**
   * Check if request should be allowed
   * @param identifier - Usually IP address
   * @param config - Rate limit configuration
   * @returns true if allowed, false if rate limited
   */
  check(identifier: string, config: RateLimitConfig): boolean {
    const now = Date.now();

    // Periodic cleanup to prevent memory leaks
    if (now - this.lastCleanup > this.CLEANUP_INTERVAL) {
      this.cleanup(config.windowMs);
    }

    // Get existing requests for this identifier
    const timestamps = this.requests.get(identifier) || [];

    // Filter to only recent requests within the window
    const recentRequests = timestamps.filter(t => now - t < config.windowMs);

    // Check if limit exceeded
    if (recentRequests.length >= config.limit) {
      return false; // Rate limited
    }

    // Add current request
    recentRequests.push(now);
    this.requests.set(identifier, recentRequests);

    // Prevent memory exhaustion - if too many identifiers, remove oldest
    if (this.requests.size > this.MAX_KEYS) {
      const firstKey = this.requests.keys().next().value;
      if (firstKey !== undefined) {
        this.requests.delete(firstKey);
      }
    }

    return true; // Allowed
  }

  /**
   * Get current request count for an identifier
   */
  getCount(identifier: string, windowMs: number): number {
    const now = Date.now();
    const timestamps = this.requests.get(identifier) || [];
    return timestamps.filter(t => now - t < windowMs).length;
  }

  /**
   * Get time until rate limit resets (in seconds)
   */
  getResetTime(identifier: string, config: RateLimitConfig): number {
    const timestamps = this.requests.get(identifier) || [];
    if (timestamps.length === 0) return 0;

    const now = Date.now();
    const recentRequests = timestamps.filter(t => now - t < config.windowMs);

    if (recentRequests.length === 0) return 0;

    const oldestRequest = Math.min(...recentRequests);
    const resetTime = oldestRequest + config.windowMs - now;

    return Math.ceil(resetTime / 1000); // Convert to seconds
  }

  /**
   * Clean up old entries to prevent memory leaks
   */
  private cleanup(windowMs: number): void {
    const now = Date.now();

    for (const [identifier, timestamps] of this.requests.entries()) {
      const recent = timestamps.filter(t => now - t < windowMs);

      if (recent.length === 0) {
        // No recent requests, remove entirely
        this.requests.delete(identifier);
      } else {
        // Update with only recent requests
        this.requests.set(identifier, recent);
      }
    }

    this.lastCleanup = now;
  }

  /**
   * Clear all rate limit data (for testing)
   */
  clear(): void {
    this.requests.clear();
  }

  /**
   * Get current stats (for monitoring)
   */
  getStats(): { totalIdentifiers: number; totalRequests: number } {
    let totalRequests = 0;
    for (const timestamps of this.requests.values()) {
      totalRequests += timestamps.length;
    }

    return {
      totalIdentifiers: this.requests.size,
      totalRequests,
    };
  }
}

// Singleton instances for different endpoints
export const pageviewRateLimiter = new RateLimiter();
export const interactionRateLimiter = new RateLimiter();

// Predefined configurations
export const RATE_LIMIT_CONFIG = {
  pageview: {
    limit: 100,           // 100 requests
    windowMs: 10 * 60 * 1000 // per 10 minutes
  },
  interaction: {
    limit: 500,           // 500 requests (higher for heatmaps)
    windowMs: 10 * 60 * 1000 // per 10 minutes
  },
  strict: {
    limit: 10,            // Very strict for suspicious activity
    windowMs: 60 * 1000   // per 1 minute
  }
} as const;

/**
 * Extract identifier (IP address) from request
 */
export function getIdentifier(request: Request): string {
  // Try to get real IP from common headers
  const forwardedFor = request.headers.get('x-forwarded-for');
  const realIp = request.headers.get('x-real-ip');
  const cfConnectingIp = request.headers.get('cf-connecting-ip'); // Cloudflare

  if (forwardedFor) {
    // x-forwarded-for can be comma-separated, take first
    return forwardedFor.split(',')[0].trim();
  }

  if (cfConnectingIp) {
    return cfConnectingIp;
  }

  if (realIp) {
    return realIp;
  }

  // Fallback to 'unknown' (should rarely happen)
  return 'unknown';
}