From 61285b02618f0f0ab8b823542291987516cd284a Mon Sep 17 00:00:00 2001 From: pac7 <47831526-pac7@users.noreply.replit.com> Date: Wed, 8 Oct 2025 17:18:00 +0000 Subject: [PATCH] Add rate limiting to location detection and standardize error messages Introduces rate limiting to the `detect-location` function and refactors error responses in `upload-image` to provide consistent, descriptive messages. Replit-Commit-Author: Agent Replit-Commit-Session-Id: b3d7d4df-59a9-4a9c-971d-175b92dadbfa Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7cdf4e95-3f41-4180-b8e3-8ef56d032c0e/b3d7d4df-59a9-4a9c-971d-175b92dadbfa/1VcvPNb --- .replit | 4 + supabase/functions/detect-location/index.ts | 54 +++++++++ supabase/functions/upload-image/index.ts | 118 +++++++++++++++----- 3 files changed, 150 insertions(+), 26 deletions(-) diff --git a/.replit b/.replit index fc81a45d..efa7acb5 100644 --- a/.replit +++ b/.replit @@ -33,3 +33,7 @@ outputType = "webview" [[ports]] localPort = 5000 externalPort = 80 + +[[ports]] +localPort = 42463 +externalPort = 3000 diff --git a/supabase/functions/detect-location/index.ts b/supabase/functions/detect-location/index.ts index c26fef0d..b75141c2 100644 --- a/supabase/functions/detect-location/index.ts +++ b/supabase/functions/detect-location/index.ts @@ -11,6 +11,40 @@ interface IPLocationResponse { measurementSystem: 'metric' | 'imperial'; } +// Simple in-memory rate limiter +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW = 60000; // 1 minute in milliseconds +const MAX_REQUESTS = 10; // 10 requests per minute per IP + +function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const existing = rateLimitMap.get(ip); + + if (!existing || now > existing.resetAt) { + // Create new entry or reset expired entry + rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); + return { allowed: true }; + } + + if (existing.count >= MAX_REQUESTS) { + const retryAfter = Math.ceil((existing.resetAt - now) / 1000); + return { allowed: false, retryAfter }; + } + + existing.count++; + return { allowed: true }; +} + +// Clean up old entries periodically to prevent memory leak +setInterval(() => { + const now = Date.now(); + for (const [ip, data] of rateLimitMap.entries()) { + if (now > data.resetAt) { + rateLimitMap.delete(ip); + } + } +}, RATE_LIMIT_WINDOW); + serve(async (req) => { // Handle CORS preflight requests if (req.method === 'OPTIONS') { @@ -23,6 +57,26 @@ serve(async (req) => { const realIP = req.headers.get('x-real-ip'); const clientIP = forwarded?.split(',')[0] || realIP || '8.8.8.8'; // fallback to Google DNS for testing + // Check rate limit + const rateLimit = checkRateLimit(clientIP); + if (!rateLimit.allowed) { + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded', + message: 'Too many requests. Please try again later.', + retryAfter: rateLimit.retryAfter + }), + { + status: 429, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'Retry-After': String(rateLimit.retryAfter || 60) + } + } + ); + } + console.log('Detecting location for IP:', clientIP); // Use configurable geolocation service with proper error handling diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts index 011dfdbb..0b804da8 100644 --- a/supabase/functions/upload-image/index.ts +++ b/supabase/functions/upload-image/index.ts @@ -30,7 +30,10 @@ serve(async (req) => { const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( - JSON.stringify({ error: 'Authentication required for delete operations' }), + JSON.stringify({ + error: 'Authentication required', + message: 'Authentication required for delete operations' + }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -49,7 +52,10 @@ serve(async (req) => { if (authError || !user) { console.error('Auth verification failed:', authError) return new Response( - JSON.stringify({ error: 'Invalid authentication' }), + JSON.stringify({ + error: 'Invalid authentication', + message: 'Authentication token is invalid or expired' + }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -67,7 +73,10 @@ serve(async (req) => { if (profileError || !profile) { console.error('Failed to fetch user profile:', profileError) return new Response( - JSON.stringify({ error: 'User profile not found' }), + JSON.stringify({ + error: 'User profile not found', + message: 'Unable to verify user profile' + }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -77,7 +86,10 @@ serve(async (req) => { if (profile.banned) { return new Response( - JSON.stringify({ error: 'Account suspended. Contact support for assistance.' }), + JSON.stringify({ + error: 'Account suspended', + message: 'Account suspended. Contact support for assistance.' + }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -91,7 +103,10 @@ serve(async (req) => { requestBody = await req.json(); } catch (error) { return new Response( - JSON.stringify({ error: 'Invalid JSON in request body' }), + JSON.stringify({ + error: 'Invalid JSON', + message: 'Request body must be valid JSON' + }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -103,7 +118,10 @@ serve(async (req) => { if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') { return new Response( - JSON.stringify({ error: 'imageId is required and must be a non-empty string' }), + JSON.stringify({ + error: 'Invalid imageId', + message: 'imageId is required and must be a non-empty string' + }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -125,7 +143,10 @@ serve(async (req) => { } catch (fetchError) { console.error('Network error deleting image:', fetchError) return new Response( - JSON.stringify({ error: 'Network error: Unable to reach Cloudflare Images API' }), + JSON.stringify({ + error: 'Network error', + message: 'Unable to reach Cloudflare Images API' + }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -139,7 +160,10 @@ serve(async (req) => { } catch (parseError) { console.error('Failed to parse Cloudflare delete response:', parseError) return new Response( - JSON.stringify({ error: 'Invalid response from Cloudflare Images API' }), + JSON.stringify({ + error: 'Invalid response', + message: 'Unable to parse response from Cloudflare Images API' + }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -151,7 +175,8 @@ serve(async (req) => { console.error('Cloudflare delete error:', deleteResult) return new Response( JSON.stringify({ - error: 'Failed to delete image', + error: 'Failed to delete image', + message: deleteResult.errors?.[0]?.message || deleteResult.error || 'Unknown error occurred', details: deleteResult.errors || deleteResult.error }), { @@ -174,7 +199,10 @@ serve(async (req) => { const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( - JSON.stringify({ error: 'Authentication required for upload operations' }), + JSON.stringify({ + error: 'Authentication required', + message: 'Authentication required for upload operations' + }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -193,7 +221,10 @@ serve(async (req) => { if (authError || !user) { console.error('Auth verification failed:', authError) return new Response( - JSON.stringify({ error: 'Invalid authentication' }), + JSON.stringify({ + error: 'Invalid authentication', + message: 'Authentication token is invalid or expired' + }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -211,7 +242,10 @@ serve(async (req) => { if (profileError || !profile) { console.error('Failed to fetch user profile:', profileError) return new Response( - JSON.stringify({ error: 'User profile not found' }), + JSON.stringify({ + error: 'User profile not found', + message: 'Unable to verify user profile' + }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -221,7 +255,10 @@ serve(async (req) => { if (profile.banned) { return new Response( - JSON.stringify({ error: 'Account suspended. Contact support for assistance.' }), + JSON.stringify({ + error: 'Account suspended', + message: 'Account suspended. Contact support for assistance.' + }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -240,7 +277,10 @@ serve(async (req) => { // Validate request body structure if (requestBody && typeof requestBody !== 'object') { return new Response( - JSON.stringify({ error: 'Request body must be a valid JSON object' }), + JSON.stringify({ + error: 'Invalid request body', + message: 'Request body must be a valid JSON object' + }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -274,7 +314,10 @@ serve(async (req) => { } catch (fetchError) { console.error('Network error getting upload URL:', fetchError) return new Response( - JSON.stringify({ error: 'Network error: Unable to reach Cloudflare Images API' }), + JSON.stringify({ + error: 'Network error', + message: 'Unable to reach Cloudflare Images API' + }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -288,7 +331,10 @@ serve(async (req) => { } catch (parseError) { console.error('Failed to parse Cloudflare upload response:', parseError) return new Response( - JSON.stringify({ error: 'Invalid response from Cloudflare Images API' }), + JSON.stringify({ + error: 'Invalid response', + message: 'Unable to parse response from Cloudflare Images API' + }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -300,7 +346,8 @@ serve(async (req) => { console.error('Cloudflare direct upload error:', directUploadResult) return new Response( JSON.stringify({ - error: 'Failed to get upload URL', + error: 'Failed to get upload URL', + message: directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL', details: directUploadResult.errors || directUploadResult.error }), { @@ -328,7 +375,10 @@ serve(async (req) => { const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( - JSON.stringify({ error: 'Authentication required for image status operations' }), + JSON.stringify({ + error: 'Authentication required', + message: 'Authentication required for image status operations' + }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -347,7 +397,10 @@ serve(async (req) => { if (authError || !user) { console.error('Auth verification failed:', authError) return new Response( - JSON.stringify({ error: 'Invalid authentication' }), + JSON.stringify({ + error: 'Invalid authentication', + message: 'Authentication token is invalid or expired' + }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -361,7 +414,10 @@ serve(async (req) => { if (!imageId || imageId.trim() === '') { return new Response( - JSON.stringify({ error: 'id query parameter is required and must be non-empty' }), + JSON.stringify({ + error: 'Missing id parameter', + message: 'id query parameter is required and must be non-empty' + }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -382,7 +438,10 @@ serve(async (req) => { } catch (fetchError) { console.error('Network error fetching image status:', fetchError) return new Response( - JSON.stringify({ error: 'Network error: Unable to reach Cloudflare Images API' }), + JSON.stringify({ + error: 'Network error', + message: 'Unable to reach Cloudflare Images API' + }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -396,7 +455,10 @@ serve(async (req) => { } catch (parseError) { console.error('Failed to parse Cloudflare image status response:', parseError) return new Response( - JSON.stringify({ error: 'Invalid response from Cloudflare Images API' }), + JSON.stringify({ + error: 'Invalid response', + message: 'Unable to parse response from Cloudflare Images API' + }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -408,7 +470,8 @@ serve(async (req) => { console.error('Cloudflare image status error:', imageResult) return new Response( JSON.stringify({ - error: 'Failed to get image status', + error: 'Failed to get image status', + message: imageResult.errors?.[0]?.message || imageResult.error || 'Unable to retrieve image information', details: imageResult.errors || imageResult.error }), { @@ -447,7 +510,10 @@ serve(async (req) => { } return new Response( - JSON.stringify({ error: 'Method not allowed' }), + JSON.stringify({ + error: 'Method not allowed', + message: 'HTTP method not supported for this endpoint' + }), { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } @@ -458,8 +524,8 @@ serve(async (req) => { console.error('Upload error:', error) return new Response( JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error' + error: 'Internal server error', + message: error instanceof Error ? error.message : 'An unexpected error occurred' }), { status: 500,