import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { corsHeaders } from '../_shared/cors.ts'; import { addSpanEvent } from '../_shared/logger.ts'; 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 const MAX_REQUESTS = 10; // 10 requests per minute per IP const MAX_MAP_SIZE = 10000; let cleanupFailureCount = 0; const MAX_CLEANUP_FAILURES = 5; const CLEANUP_FAILURE_RESET_INTERVAL = 300000; // 5 minutes function cleanupExpiredEntries() { try { const now = Date.now(); for (const [ip, data] of rateLimitMap.entries()) { if (now > data.resetAt) { rateLimitMap.delete(ip); } } if (cleanupFailureCount > 0) { cleanupFailureCount = 0; } } catch (error: unknown) { cleanupFailureCount++; if (cleanupFailureCount >= MAX_CLEANUP_FAILURES) { try { const entriesToClear = Math.floor(rateLimitMap.size * 0.5); const sortedEntries = Array.from(rateLimitMap.entries()) .sort((a, b) => a[1].resetAt - b[1].resetAt); for (let i = 0; i < entriesToClear && i < sortedEntries.length; i++) { rateLimitMap.delete(sortedEntries[i][0]); } cleanupFailureCount = 0; } catch { rateLimitMap.clear(); cleanupFailureCount = 0; } } } } setInterval(() => { if (cleanupFailureCount > 0) { cleanupFailureCount = 0; } }, CLEANUP_FAILURE_RESET_INTERVAL); function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { const now = Date.now(); const existing = rateLimitMap.get(ip); 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 }; } if (!existing && rateLimitMap.size >= MAX_MAP_SIZE) { cleanupExpiredEntries(); if (rateLimitMap.size >= MAX_MAP_SIZE) { try { const toDelete = Math.floor(MAX_MAP_SIZE * 0.3); const sortedEntries = Array.from(rateLimitMap.entries()) .sort((a, b) => a[1].resetAt - b[1].resetAt); for (let i = 0; i < toDelete && i < sortedEntries.length; i++) { rateLimitMap.delete(sortedEntries[i][0]); } } catch { rateLimitMap.clear(); } } } rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); return { allowed: true }; } setInterval(cleanupExpiredEntries, Math.min(RATE_LIMIT_WINDOW / 2, 30000)); const handler = async (req: Request, { span, requestId }: EdgeFunctionContext) => { // Get client IP 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'; // Check rate limit const rateLimit = checkRateLimit(clientIP); if (!rateLimit.allowed) { addSpanEvent(span, 'rate_limit_exceeded', { clientIP: clientIP.substring(0, 8) + '...' }); return new Response( JSON.stringify({ error: 'Rate limit exceeded', message: 'Too many requests. Please try again later.', retryAfter: rateLimit.retryAfter }), { status: 429, headers: { 'Retry-After': String(rateLimit.retryAfter || 60) } } ); } addSpanEvent(span, 'detecting_location', { requestId }); // Use configurable geolocation service 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) { addSpanEvent(span, 'network_error', { error: fetchError instanceof Error ? fetchError.message : String(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) { addSpanEvent(span, 'parse_error', { error: parseError instanceof Error ? parseError.message : String(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']; const measurementSystem = imperialCountries.includes(geoData.countryCode) ? 'imperial' : 'metric'; const result: IPLocationResponse = { country: geoData.country, countryCode: geoData.countryCode, measurementSystem }; addSpanEvent(span, 'location_detected', { country: result.country, countryCode: result.countryCode, measurementSystem: result.measurementSystem }); return result; }; serve(createEdgeFunction({ name: 'detect-location', requireAuth: false, corsHeaders, enableTracing: true, logRequests: true, }, handler));