Files
thrilltrack-explorer/supabase/functions/detect-location/index.ts
pac7 ac65e4b4db Improve location data handling and error reporting in the app
Update LocationSearch component to safely access address properties and add fallback values for city, state, and country. Refactor supabase/functions/detect-location/index.ts to use a generic endRequest function, removing hardcoded status codes for error handling.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 3c76e833-eccc-4712-b9a4-70241b5590a1
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
2025-10-27 23:39:30 +00:00

314 lines
11 KiB
TypeScript

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<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++;
}
}
// 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);
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);
// 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
}
);
}
});