interface IpLocationData { ip: string; country_name: string; country_code: string; city: string; region: string; continent_code: string; continent_name: string; latitude: number; longitude: number; timestamp?: number; // When this data was fetched } // In-memory cache let locationCache: Record = {}; // Blacklist for IPs that failed to resolve multiple times let failedIPs: Record = {}; // Cache expiration time (30 days in milliseconds) const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000; // Max retries for a failed IP const MAX_RETRY_ATTEMPTS = 3; // Retry timeout (24 hours in milliseconds) const RETRY_TIMEOUT = 24 * 60 * 60 * 1000; // Max number of IPs to batch in a single request const MAX_BATCH_SIZE = 10; /** * Initialize cache from localStorage */ const initializeCache = () => { if (typeof window === 'undefined') return; try { // Load location cache const cachedData = localStorage.getItem('ip-location-cache'); if (cachedData) { const parsedCache = JSON.parse(cachedData); // Filter out expired entries const now = Date.now(); const validEntries = Object.entries(parsedCache).filter(([, data]) => { const entry = data as IpLocationData; return entry.timestamp && now - entry.timestamp < CACHE_EXPIRATION; }); locationCache = Object.fromEntries(validEntries) as Record; console.log(`Loaded ${validEntries.length} IP locations from cache`); } // Load failed IPs const failedIPsData = localStorage.getItem('ip-location-blacklist'); if (failedIPsData) { const parsedFailedIPs = JSON.parse(failedIPsData); // Filter out expired blacklist entries const now = Date.now(); const validFailedEntries = Object.entries(parsedFailedIPs).filter(([, data]) => { const entry = data as { attempts: number, lastAttempt: number }; // Keep entries that have max attempts or haven't timed out yet return entry.attempts >= MAX_RETRY_ATTEMPTS || now - entry.lastAttempt < RETRY_TIMEOUT; }); failedIPs = Object.fromEntries(validFailedEntries) as Record; console.log(`Loaded ${validFailedEntries.length} blacklisted IPs`); } } catch (error) { console.error('Failed to load IP location cache:', error); // Reset cache if corrupted localStorage.removeItem('ip-location-cache'); localStorage.removeItem('ip-location-blacklist'); locationCache = {}; failedIPs = {}; } }; /** * Save cache to localStorage */ const saveCache = () => { if (typeof window === 'undefined') return; try { localStorage.setItem('ip-location-cache', JSON.stringify(locationCache)); } catch (error) { console.error('Failed to save IP location cache:', error); // If localStorage is full, clear old entries if (error instanceof DOMException && error.name === 'QuotaExceededError') { // Clear older entries - keep newest 100 const entries = Object.entries(locationCache) .sort((a, b) => { const timestampA = (a[1].timestamp || 0); const timestampB = (b[1].timestamp || 0); return timestampB - timestampA; }) .slice(0, 100); locationCache = Object.fromEntries(entries); localStorage.setItem('ip-location-cache', JSON.stringify(locationCache)); } } }; /** * Save failed IPs to localStorage */ const saveFailedIPs = () => { if (typeof window === 'undefined') return; try { localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs)); } catch (error) { console.error('Failed to save IP blacklist:', error); // If localStorage is full, limit the size if (error instanceof DOMException && error.name === 'QuotaExceededError') { // Keep only IPs with max attempts const entries = Object.entries(failedIPs) .filter(([, data]) => data.attempts >= MAX_RETRY_ATTEMPTS); failedIPs = Object.fromEntries(entries); localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs)); } } }; /** * Check if IP is a private/local address */ const isPrivateIP = (ip: string): boolean => { return ( ip.startsWith('10.') || ip.startsWith('192.168.') || ip.startsWith('172.16.') || ip.startsWith('172.17.') || ip.startsWith('172.18.') || ip.startsWith('172.19.') || ip.startsWith('172.20.') || ip.startsWith('172.21.') || ip.startsWith('172.22.') || ip.startsWith('127.') || ip === 'localhost' || ip === '::1' ); }; /** * Check if an IP should be skipped (blacklisted) */ const shouldSkipIP = (ip: string): boolean => { // If not in failed list, don't skip if (!failedIPs[ip]) return false; const now = Date.now(); // If reached max attempts, skip if (failedIPs[ip].attempts >= MAX_RETRY_ATTEMPTS) { return true; } // If hasn't been long enough since last attempt, skip if (now - failedIPs[ip].lastAttempt < RETRY_TIMEOUT) { return true; } // Otherwise, we can try again return false; }; /** * Mark IP as failed */ const markIPAsFailed = (ip: string): void => { const now = Date.now(); if (failedIPs[ip]) { failedIPs[ip] = { attempts: failedIPs[ip].attempts + 1, lastAttempt: now }; } else { failedIPs[ip] = { attempts: 1, lastAttempt: now }; } saveFailedIPs(); }; /** * Get location data for a single IP address */ const fetchSingleIP = async (ip: string): Promise => { // Skip blacklisted IPs if (shouldSkipIP(ip)) { console.log(`Skipping blacklisted IP: ${ip}`); return null; } try { const response = await fetch(`https://ipapi.co/${ip}/json/`); if (!response.ok) { console.error(`Error fetching location for IP ${ip}: ${response.statusText}`); markIPAsFailed(ip); return null; } const data = await response.json(); if (data.error) { console.error(`Error fetching location for IP ${ip}: ${data.reason}`); markIPAsFailed(ip); return null; } // Reset failed attempts if successful if (failedIPs[ip]) { delete failedIPs[ip]; saveFailedIPs(); } const locationData: IpLocationData = { ip: data.ip, country_name: data.country_name || 'Unknown', country_code: data.country_code || 'UN', city: data.city || 'Unknown', region: data.region || 'Unknown', continent_code: data.continent_code || 'UN', continent_name: getContinentName(data.continent_code) || 'Unknown', latitude: data.latitude || 0, longitude: data.longitude || 0, timestamp: Date.now() }; return locationData; } catch (error) { console.error(`Error fetching location for IP ${ip}:`, error); markIPAsFailed(ip); return null; } }; /** * Batch process multiple IPs at once using our own API endpoint * This is a placeholder - we'll create a server API route for this */ const fetchBatchIPs = async (ips: string[]): Promise> => { try { // Filter out blacklisted IPs const validIPs = ips.filter(ip => !shouldSkipIP(ip)); if (validIPs.length === 0) { return {}; } const response = await fetch('/api/geo/batch', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ ips: validIPs }), }); if (!response.ok) { throw new Error(`Batch request failed: ${response.statusText}`); } const results = await response.json(); // Mark failed IPs from results for (const [ip, data] of Object.entries(results.data)) { if (!data) { markIPAsFailed(ip); } else if (failedIPs[ip]) { // Reset failed attempts if successful delete failedIPs[ip]; } } saveFailedIPs(); return results.data; } catch (error) { console.error('Error in batch IP lookup:', error); // Fallback to individual requests const results: Record = {}; for (const ip of ips) { // Skip blacklisted IPs if (shouldSkipIP(ip)) { results[ip] = null; continue; } // Add delays between requests to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 200)); results[ip] = await fetchSingleIP(ip); } return results; } }; /** * Handle private IP addresses */ const getPrivateIPData = (ip: string): IpLocationData => ({ ip, country_name: 'Local Network', country_code: 'LO', city: 'Local', region: 'Local', continent_code: 'LO', continent_name: 'Local', latitude: 0, longitude: 0, timestamp: Date.now() }); /** * Convert an IP address to location information * Individual lookup for a single IP */ export async function getLocationFromIP(ip: string): Promise { // Initialize cache from localStorage if needed if (Object.keys(locationCache).length === 0) { initializeCache(); } // Handle private IP addresses if (isPrivateIP(ip)) { const privateIPData = getPrivateIPData(ip); locationCache[ip] = privateIPData; return privateIPData; } // Skip blacklisted IPs if (shouldSkipIP(ip)) { console.log(`Skipping blacklisted IP: ${ip}`); return null; } // Return from cache if available and not expired if (locationCache[ip]) { const cachedData = locationCache[ip]; const now = Date.now(); // Return cached data if not expired if (cachedData.timestamp && now - cachedData.timestamp < CACHE_EXPIRATION) { return cachedData; } } // Fetch new data const locationData = await fetchSingleIP(ip); // Save to cache if successful if (locationData) { locationCache[ip] = locationData; saveCache(); } return locationData; } /** * Batch lookup for multiple IPs at once * More efficient than calling getLocationFromIP multiple times */ export async function getLocationsFromIPs(ips: string[]): Promise> { // Initialize cache from localStorage if needed if (Object.keys(locationCache).length === 0) { initializeCache(); } // Filter out IPs that are already in cache and not expired const now = Date.now(); const cachedResults: Record = {}; const ipsToFetch: string[] = []; for (const ip of ips) { // Handle private IPs if (isPrivateIP(ip)) { cachedResults[ip] = getPrivateIPData(ip); continue; } // Skip blacklisted IPs if (shouldSkipIP(ip)) { console.log(`Skipping blacklisted IP: ${ip}`); continue; } // Check cache if (locationCache[ip] && locationCache[ip].timestamp && now - locationCache[ip].timestamp < CACHE_EXPIRATION) { cachedResults[ip] = locationCache[ip]; } else { ipsToFetch.push(ip); } } // If all IPs were cached or blacklisted, return immediately if (ipsToFetch.length === 0) { return cachedResults; } // Process IPs in batches to avoid overwhelming the API const results: Record = { ...cachedResults }; // Process in smaller batches (e.g., 10 IPs at a time) for (let i = 0; i < ipsToFetch.length; i += MAX_BATCH_SIZE) { const batchIPs = ipsToFetch.slice(i, i + MAX_BATCH_SIZE); // Batch request const batchResults = await fetchBatchIPs(batchIPs); // Update results and cache for (const [ip, data] of Object.entries(batchResults)) { results[ip] = data; if (data) { locationCache[ip] = data; } } // Save updated cache saveCache(); // Add delay between batches if (i + MAX_BATCH_SIZE < ipsToFetch.length) { await new Promise(resolve => setTimeout(resolve, 1000)); } } return results; } /** * Get continent name from continent code */ function getContinentName(code?: string): string { if (!code) return 'Unknown'; const continents: Record = { 'AF': 'Africa', 'AN': 'Antarctica', 'AS': 'Asia', 'EU': 'Europe', 'NA': 'North America', 'OC': 'Oceania', 'SA': 'South America' }; return continents[code] || 'Unknown'; } /** * Get location information based on view mode */ export function getLocationByType( locationData: IpLocationData | null, viewMode: 'country' | 'city' | 'region' | 'continent' ): string { if (!locationData) return 'Unknown'; switch (viewMode) { case 'country': return locationData.country_name || 'Unknown'; case 'city': return locationData.city || 'Unknown'; case 'region': return locationData.region || 'Unknown'; case 'continent': return locationData.continent_name || 'Unknown'; default: return 'Unknown'; } }