From e9082be2d92c08ac0ed7ed91842c3d56c98cf876 Mon Sep 17 00:00:00 2001 From: Damjan Savic Date: Sun, 25 Jan 2026 06:41:28 +0100 Subject: [PATCH] auto-claude: subtask-2-1 - Create Supabase-based rate limiting utility --- src/utils/rateLimitSupabase.ts | 163 +++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/utils/rateLimitSupabase.ts diff --git a/src/utils/rateLimitSupabase.ts b/src/utils/rateLimitSupabase.ts new file mode 100644 index 0000000..0c308bf --- /dev/null +++ b/src/utils/rateLimitSupabase.ts @@ -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 { + 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 { + 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 { + 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 };