auto-claude: subtask-2-1 - Create Supabase-based rate limiting utility
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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> {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
|
||||
try {
|
||||
// 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('Unexpected error in rate limiting:', error);
|
||||
return false; // Fail open on unexpected 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> {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
|
||||
try {
|
||||
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('Unexpected error getting remaining attempts:', error);
|
||||
return MAX_REQUESTS; // Fail open
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const supabase = await createClient();
|
||||
const now = Date.now();
|
||||
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
||||
|
||||
try {
|
||||
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('Unexpected error getting time to reset:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supabaseRateLimiter = new SupabaseRateLimiter();
|
||||
export { WINDOW_SIZE_MS, MAX_REQUESTS };
|
||||
Reference in New Issue
Block a user