Files
shorturl-analytics/app/utils/ipLocation.ts
2025-04-02 22:23:49 +08:00

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';
}
}