Files
thrilltrack-explorer/supabase/functions/detect-location/index.ts
gpt-engineer-app[bot] 2d65f13b85 Connect to Lovable Cloud
Add centralized errorFormatter to convert various error types into readable messages, and apply it across edge functions. Replace String(error) usage with formatEdgeError, update relevant imports, fix a throw to use toError, and enhance logger to log formatted errors. Includes new errorFormatter.ts and widespread updates to 18+ edge functions plus logger integration.
2025-11-10 18:09:15 +00:00

322 lines
11 KiB
TypeScript

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
import { formatEdgeError } from "../_shared/errorFormatter.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<string, { count: number; resetAt: number }>();
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++;
}
}
// Cleanup runs silently unless there are issues
if (cleanupFailureCount > 0) {
cleanupFailureCount = 0;
}
} catch (error: unknown) {
// CRITICAL: Increment failure counter and log detailed error information
cleanupFailureCount++;
const errorMessage = formatEdgeError(error);
edgeLogger.error('Cleanup error', {
attempt: cleanupFailureCount,
maxAttempts: MAX_CLEANUP_FAILURES,
error: errorMessage,
mapSize: rateLimitMap.size
});
// FALLBACK MECHANISM: If cleanup fails repeatedly, force clear to prevent memory leak
if (cleanupFailureCount >= MAX_CLEANUP_FAILURES) {
edgeLogger.error('Cleanup critical - forcing emergency cleanup', {
consecutiveFailures: cleanupFailureCount,
mapSize: rateLimitMap.size
});
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++;
}
edgeLogger.warn('Emergency cleanup completed', { clearedCount, newMapSize: rateLimitMap.size });
// Reset failure count after emergency cleanup
cleanupFailureCount = 0;
} catch (emergencyError) {
// Last resort: If even emergency cleanup fails, clear everything
const originalSize = rateLimitMap.size;
rateLimitMap.clear();
edgeLogger.error('Emergency cleanup failed - cleared entire map', {
originalSize,
error: emergencyError instanceof Error ? emergencyError.message : String(emergencyError)
});
cleanupFailureCount = 0;
}
}
}
}
// Reset cleanup failure count periodically to avoid permanent emergency state
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);
// 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++;
}
edgeLogger.warn('Rate limit map at capacity - evicted entries', {
maxSize: MAX_MAP_SIZE,
deletedCount,
newSize: rateLimitMap.size
});
} catch (evictionError) {
// CRITICAL: LRU eviction failed - log error and attempt emergency clear
edgeLogger.error('LRU eviction failed', {
error: evictionError instanceof Error ? evictionError.message : String(evictionError),
mapSize: 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));
edgeLogger.warn('Emergency eviction completed', {
clearedCount: keysToDelete.length,
newSize: rateLimitMap.size
});
} catch (emergencyError) {
edgeLogger.error('Emergency eviction failed - clearing entire map', {
error: emergencyError instanceof Error ? emergencyError.message : String(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
edgeLogger.info('Detecting location for request', { requestId: tracking.requestId });
// 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) {
edgeLogger.error('Network error fetching location data', {
error: fetchError instanceof Error ? fetchError.message : String(fetchError),
requestId: tracking.requestId
});
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) {
edgeLogger.error('Failed to parse geolocation response', {
error: parseError instanceof Error ? parseError.message : String(parseError),
requestId: tracking.requestId
});
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
};
edgeLogger.info('Location detected', {
country: result.country,
countryCode: result.countryCode,
measurementSystem: result.measurementSystem,
requestId: tracking.requestId
});
endRequest(tracking);
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 = formatEdgeError(error);
edgeLogger.error('Location detection error', {
error: errorMessage,
requestId: tracking.requestId
});
endRequest(tracking);
// 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
}
);
}
});