import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { startRequest, endRequest } from "../_shared/logger.ts"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-request-id', }; interface IPLocationResponse { country: string; countryCode: string; 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 const MAX_MAP_SIZE = 10000; // Maximum number of IPs to track // Cleanup failure tracking to prevent silent failures let cleanupFailureCount = 0; const MAX_CLEANUP_FAILURES = 5; // Threshold before forcing drastic cleanup const CLEANUP_FAILURE_RESET_INTERVAL = 300000; // Reset failure count every 5 minutes function cleanupExpiredEntries() { try { const now = Date.now(); let deletedCount = 0; const mapSizeBefore = rateLimitMap.size; for (const [ip, data] of rateLimitMap.entries()) { if (now > data.resetAt) { rateLimitMap.delete(ip); deletedCount++; } } // Log cleanup activity for monitoring if (deletedCount > 0) { console.log(`[Cleanup] Removed ${deletedCount} expired entries. Map size: ${mapSizeBefore} -> ${rateLimitMap.size}`); } // Reset failure count on successful cleanup if (cleanupFailureCount > 0) { console.log(`[Cleanup] Successful cleanup after ${cleanupFailureCount} previous failures. Resetting failure count.`); cleanupFailureCount = 0; } } catch (error: unknown) { // CRITICAL: Increment failure counter and log detailed error information cleanupFailureCount++; const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : 'No stack trace available'; console.error('[Cleanup Error]', { attempt: cleanupFailureCount, maxAttempts: MAX_CLEANUP_FAILURES, error: errorMessage, stack: errorStack, mapSize: rateLimitMap.size }); // FALLBACK MECHANISM: If cleanup fails repeatedly, force clear to prevent memory leak if (cleanupFailureCount >= MAX_CLEANUP_FAILURES) { console.error(`[Cleanup CRITICAL] Cleanup has failed ${cleanupFailureCount} times consecutively!`); console.error(`[Cleanup CRITICAL] Forcing emergency cleanup to prevent memory leak...`); try { // Emergency: Clear oldest 50% of entries to prevent unbounded growth const entriesToClear = Math.floor(rateLimitMap.size * 0.5); const sortedEntries = Array.from(rateLimitMap.entries()) .sort((a, b) => a[1].resetAt - b[1].resetAt); let clearedCount = 0; for (let i = 0; i < entriesToClear && i < sortedEntries.length; i++) { rateLimitMap.delete(sortedEntries[i][0]); clearedCount++; } console.warn(`[Cleanup CRITICAL] Emergency cleanup completed. Cleared ${clearedCount} entries. Map size: ${rateLimitMap.size}`); // Reset failure count after emergency cleanup cleanupFailureCount = 0; } catch (emergencyError) { // Last resort: If even emergency cleanup fails, clear everything console.error(`[Cleanup CRITICAL] Emergency cleanup failed! Clearing entire rate limit map.`); console.error(`[Cleanup CRITICAL] Emergency error: ${emergencyError}`); const originalSize = rateLimitMap.size; rateLimitMap.clear(); console.warn(`[Cleanup CRITICAL] Cleared entire rate limit map (${originalSize} entries) to prevent memory leak.`); cleanupFailureCount = 0; } } } } // Reset cleanup failure count periodically to avoid permanent emergency state setInterval(() => { if (cleanupFailureCount > 0) { console.warn(`[Cleanup] Resetting failure count (was ${cleanupFailureCount}) after timeout period.`); cleanupFailureCount = 0; } }, CLEANUP_FAILURE_RESET_INTERVAL); function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { const now = Date.now(); const existing = rateLimitMap.get(ip); // Handle existing entries (most common case - early return for performance) if (existing && now <= existing.resetAt) { if (existing.count >= MAX_REQUESTS) { const retryAfter = Math.ceil((existing.resetAt - now) / 1000); return { allowed: false, retryAfter }; } existing.count++; return { allowed: true }; } // Need to add new entry or reset expired one // Only perform cleanup if we're at capacity AND adding a new IP if (!existing && rateLimitMap.size >= MAX_MAP_SIZE) { // First try cleaning expired entries cleanupExpiredEntries(); // If still at capacity after cleanup, remove oldest entries (LRU eviction) if (rateLimitMap.size >= MAX_MAP_SIZE) { try { const toDelete = Math.floor(MAX_MAP_SIZE * 0.3); // Remove 30% of entries const sortedEntries = Array.from(rateLimitMap.entries()) .sort((a, b) => a[1].resetAt - b[1].resetAt); let deletedCount = 0; for (let i = 0; i < toDelete && i < sortedEntries.length; i++) { rateLimitMap.delete(sortedEntries[i][0]); deletedCount++; } console.warn(`[Rate Limit] Map reached ${MAX_MAP_SIZE} entries. Cleared ${deletedCount} oldest entries. New size: ${rateLimitMap.size}`); } catch (evictionError) { // CRITICAL: LRU eviction failed - log error and attempt emergency clear console.error(`[Rate Limit CRITICAL] LRU eviction failed! Error: ${evictionError}`); console.error(`[Rate Limit CRITICAL] Map size: ${rateLimitMap.size}`); try { // Emergency: Clear first 30% of entries without sorting const targetSize = Math.floor(MAX_MAP_SIZE * 0.7); const keysToDelete: string[] = []; for (const [key] of rateLimitMap.entries()) { if (rateLimitMap.size <= targetSize) break; keysToDelete.push(key); } keysToDelete.forEach(key => rateLimitMap.delete(key)); console.warn(`[Rate Limit CRITICAL] Emergency eviction cleared ${keysToDelete.length} entries. New size: ${rateLimitMap.size}`); } catch (emergencyError) { console.error(`[Rate Limit CRITICAL] Emergency eviction also failed! Clearing entire map. Error: ${emergencyError}`); rateLimitMap.clear(); } } } } // Create new entry or reset expired entry rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); return { allowed: true }; } // Clean up old entries periodically to prevent memory leak // Run cleanup more frequently to catch expired entries sooner setInterval(cleanupExpiredEntries, Math.min(RATE_LIMIT_WINDOW / 2, 30000)); // Every 30 seconds or half the window serve(async (req) => { // Handle CORS preflight requests if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } const tracking = startRequest('detect-location'); try { // Get the client's IP address const forwarded = req.headers.get('x-forwarded-for'); 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) } } ); } // PII Note: Do not log full IP addresses in production console.log('[Location] Detecting location for request'); // Use configurable geolocation service with proper error handling // Defaults to ip-api.com if not configured const geoApiUrl = Deno.env.get('GEOLOCATION_API_URL') || 'http://ip-api.com/json'; const geoApiFields = Deno.env.get('GEOLOCATION_API_FIELDS') || 'status,country,countryCode'; let geoResponse; try { geoResponse = await fetch(`${geoApiUrl}/${clientIP}?fields=${geoApiFields}`); } catch (fetchError) { console.error('Network error fetching location data:', fetchError); throw new Error('Network error: Unable to reach geolocation service'); } if (!geoResponse.ok) { throw new Error(`Geolocation service returned ${geoResponse.status}: ${geoResponse.statusText}`); } let geoData; try { geoData = await geoResponse.json(); } catch (parseError) { console.error('Failed to parse geolocation response:', parseError); throw new Error('Invalid response format from geolocation service'); } if (geoData.status !== 'success') { throw new Error(`Geolocation failed: ${geoData.message || 'Invalid location data'}`); } // Countries that primarily use imperial system const imperialCountries = ['US', 'LR', 'MM']; // USA, Liberia, Myanmar const measurementSystem = imperialCountries.includes(geoData.countryCode) ? 'imperial' : 'metric'; const result: IPLocationResponse = { country: geoData.country, countryCode: geoData.countryCode, measurementSystem }; console.log('[Location] Location detected:', { country: result.country, countryCode: result.countryCode, measurementSystem: result.measurementSystem, requestId: tracking.requestId }); endRequest(tracking, 200); return new Response( JSON.stringify({ ...result, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } catch (error: unknown) { // Enhanced error logging for better visibility and debugging const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : 'No stack trace available'; console.error('[Location Detection Error]', { error: errorMessage, stack: errorStack, hasIP: true, // IP removed for PII protection requestId: tracking.requestId }); endRequest(tracking, 500, errorMessage); // Return default (metric) with 500 status to indicate error occurred // This allows proper error monitoring while still providing fallback data const defaultResult: IPLocationResponse = { country: 'Unknown', countryCode: 'XX', measurementSystem: 'metric' }; return new Response( JSON.stringify({ ...defaultResult, error: errorMessage, fallback: true, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, status: 500 } ); } });