484 lines
13 KiB
TypeScript
484 lines
13 KiB
TypeScript
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<string, IpLocationData> = {};
|
|
|
|
// Blacklist for IPs that failed to resolve multiple times
|
|
let failedIPs: Record<string, { attempts: number, lastAttempt: number }> = {};
|
|
|
|
// 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<string, IpLocationData>;
|
|
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<string, { attempts: number, lastAttempt: number }>;
|
|
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<IpLocationData | null> => {
|
|
// 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<Record<string, IpLocationData | null>> => {
|
|
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<string, IpLocationData | null> = {};
|
|
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<IpLocationData | null> {
|
|
// 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<Record<string, IpLocationData | null>> {
|
|
// 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<string, IpLocationData> = {};
|
|
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<string, IpLocationData | null> = { ...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<string, string> = {
|
|
'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';
|
|
}
|
|
}
|