Merge origin/master
This commit is contained in:
+42
-2
@@ -10,6 +10,7 @@ type CacheItem<T> = {
|
||||
value: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
lastAccessed: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,14 +19,20 @@ type CacheItem<T> = {
|
||||
class Cache {
|
||||
private storage: Map<string, CacheItem<any>>;
|
||||
private readonly defaultTTL: number;
|
||||
private readonly maxSize?: number;
|
||||
|
||||
<<<<<<< HEAD
|
||||
/**
|
||||
* Create a new cache instance
|
||||
* @param defaultTTL - Default time-to-live in milliseconds (default: 5 minutes)
|
||||
*/
|
||||
constructor(defaultTTL = 5 * 60 * 1000) { // 5 minutes default TTL
|
||||
=======
|
||||
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) { // 5 minutes default TTL
|
||||
>>>>>>> origin/master
|
||||
this.storage = new Map();
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,19 +42,46 @@ class Cache {
|
||||
* @param ttl - Time-to-live in milliseconds (uses default if not specified)
|
||||
*/
|
||||
set<T>(key: string, value: T, ttl = this.defaultTTL): void {
|
||||
const now = Date.now();
|
||||
|
||||
// If maxSize is set and cache is full, evict least recently used item
|
||||
if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) {
|
||||
this.evictLRU();
|
||||
}
|
||||
|
||||
this.storage.set(key, {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
ttl
|
||||
timestamp: now,
|
||||
ttl,
|
||||
lastAccessed: now
|
||||
});
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/**
|
||||
* Retrieve a value from cache
|
||||
* Returns null if key doesn't exist or has expired
|
||||
* @param key - Cache key
|
||||
* @returns Cached value or null
|
||||
*/
|
||||
=======
|
||||
private evictLRU(): void {
|
||||
let lruKey: string | null = null;
|
||||
let lruTime = Infinity;
|
||||
|
||||
for (const [key, item] of this.storage.entries()) {
|
||||
if (item.lastAccessed < lruTime) {
|
||||
lruTime = item.lastAccessed;
|
||||
lruKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (lruKey) {
|
||||
this.storage.delete(lruKey);
|
||||
}
|
||||
}
|
||||
|
||||
>>>>>>> origin/master
|
||||
get<T>(key: string): T | null {
|
||||
const item = this.storage.get(key);
|
||||
|
||||
@@ -58,6 +92,12 @@ class Cache {
|
||||
return null;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
// Update last accessed time for LRU tracking
|
||||
item.lastAccessed = Date.now();
|
||||
|
||||
>>>>>>> origin/master
|
||||
return item.value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Extract client IP address from Next.js request headers
|
||||
*
|
||||
* This function handles various proxy and forwarding scenarios:
|
||||
* - Vercel's x-forwarded-for header (may contain multiple IPs)
|
||||
* - x-real-ip header (direct client IP)
|
||||
* - Fallback for development environments
|
||||
*
|
||||
* @param request - Next.js request object
|
||||
* @returns Client IP address as a string, or 'unknown-ip' as fallback
|
||||
*/
|
||||
export function getClientIp(request: NextRequest): string {
|
||||
// Check Vercel's forwarded IP header first
|
||||
// In production, this is the most reliable source
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for may contain multiple IPs in format: "client, proxy1, proxy2"
|
||||
// The first IP is the original client IP
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Check for real IP header (used by some proxies and load balancers)
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback to a default identifier for development environments
|
||||
// or when headers are not available
|
||||
return 'unknown-ip';
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
interface RateLimitRecord {
|
||||
identifier: string;
|
||||
count: number;
|
||||
window_start: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
const WINDOW_SIZE_MS = 3600000; // 1 hour
|
||||
const MAX_REQUESTS = 5; // Maximum requests per hour
|
||||
|
||||
class SupabaseRateLimiter {
|
||||
/**
|
||||
* Check if an identifier (IP address) is rate limited
|
||||
* @param identifier - The IP address or unique identifier to check
|
||||
* @returns true if rate limited, false otherwise
|
||||
*/
|
||||
async isRateLimited(identifier: string): Promise<boolean> {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
// Get the current rate limit record for this identifier
|
||||
const { data: existingRecord, error: fetchError } = await supabase
|
||||
.from('rate_limits')
|
||||
.select('*')
|
||||
.eq('identifier', identifier)
|
||||
.gte('window_start', windowStart)
|
||||
.order('window_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
// PGRST116 is "no rows returned" which is fine
|
||||
console.error('Error fetching rate limit:', fetchError);
|
||||
return false; // Fail open - don't block on errors
|
||||
}
|
||||
|
||||
// No existing record or window expired - create new record
|
||||
if (!existingRecord || new Date(existingRecord.window_start).getTime() < now - WINDOW_SIZE_MS) {
|
||||
const { error: insertError } = await supabase
|
||||
.from('rate_limits')
|
||||
.insert({
|
||||
identifier,
|
||||
count: 1,
|
||||
window_start: new Date(now).toISOString()
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error inserting rate limit:', insertError);
|
||||
return false; // Fail open
|
||||
}
|
||||
|
||||
return false; // First request in new window
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (existingRecord.count >= MAX_REQUESTS) {
|
||||
return true; // Rate limited
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
const { error: updateError } = await supabase
|
||||
.from('rate_limits')
|
||||
.update({
|
||||
count: existingRecord.count + 1,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('identifier', identifier)
|
||||
.eq('window_start', existingRecord.window_start);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating rate limit:', updateError);
|
||||
return false; // Fail open
|
||||
}
|
||||
|
||||
return false; // Not rate limited yet
|
||||
} catch (error) {
|
||||
console.error('[RateLimiter] Unexpected error in isRateLimited - FAILING OPEN:', error);
|
||||
return false; // Fail open on ALL errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of remaining attempts for an identifier
|
||||
* @param identifier - The IP address or unique identifier to check
|
||||
* @returns Number of remaining attempts (0 if rate limited)
|
||||
*/
|
||||
async getRemainingAttempts(identifier: string): Promise<number> {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
const { data: record, error } = await supabase
|
||||
.from('rate_limits')
|
||||
.select('count')
|
||||
.eq('identifier', identifier)
|
||||
.gte('window_start', windowStart)
|
||||
.order('window_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
console.error('Error fetching remaining attempts:', error);
|
||||
return MAX_REQUESTS; // Fail open
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return MAX_REQUESTS; // No record yet
|
||||
}
|
||||
|
||||
return Math.max(0, MAX_REQUESTS - record.count);
|
||||
} catch (error) {
|
||||
console.error('[RateLimiter] Unexpected error in getRemainingAttempts - FAILING OPEN:', error);
|
||||
return MAX_REQUESTS; // Fail open on ALL errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time in milliseconds until the rate limit resets
|
||||
* @param identifier - The IP address or unique identifier to check
|
||||
* @returns Milliseconds until reset (0 if no active limit)
|
||||
*/
|
||||
async getTimeToReset(identifier: string): Promise<number> {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
const { data: record, error } = await supabase
|
||||
.from('rate_limits')
|
||||
.select('window_start')
|
||||
.eq('identifier', identifier)
|
||||
.gte('window_start', windowStart)
|
||||
.order('window_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
console.error('Error fetching time to reset:', error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return 0; // No active window
|
||||
}
|
||||
|
||||
const windowStartTime = new Date(record.window_start).getTime();
|
||||
const resetTime = windowStartTime + WINDOW_SIZE_MS;
|
||||
return Math.max(0, resetTime - now);
|
||||
} catch (error) {
|
||||
console.error('[RateLimiter] Unexpected error in getTimeToReset - FAILING OPEN:', error);
|
||||
return 0; // Fail open on ALL errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supabaseRateLimiter = new SupabaseRateLimiter();
|
||||
export { WINDOW_SIZE_MS, MAX_REQUESTS };
|
||||
Reference in New Issue
Block a user