geo main
This commit is contained in:
248
app/api/geo/batch/route.ts
Normal file
248
app/api/geo/batch/route.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import type { ApiResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple in-memory cache on the server side to reduce API calls
|
||||||
|
const serverCache: Record<string, IpLocationData> = {};
|
||||||
|
|
||||||
|
// Cache for IPs that have repeatedly failed to resolve
|
||||||
|
const failedIPsCache: Record<string, { attempts: number, lastAttempt: number }> = {};
|
||||||
|
|
||||||
|
// Cache expiration time (30 days in milliseconds)
|
||||||
|
const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
// Max attempts to fetch an IP before considering it permanently failed
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
// Retry timeout - how long to wait before trying a failed IP again (24 hours)
|
||||||
|
const RETRY_TIMEOUT = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP has failed too many times and should be skipped
|
||||||
|
*/
|
||||||
|
function shouldSkipIP(ip: string): boolean {
|
||||||
|
if (!failedIPsCache[ip]) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Skip if max attempts reached
|
||||||
|
if (failedIPsCache[ip].attempts >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if last attempt was recent
|
||||||
|
if (now - failedIPsCache[ip].lastAttempt < RETRY_TIMEOUT) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an IP as failed
|
||||||
|
*/
|
||||||
|
function markIPAsFailed(ip: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (failedIPsCache[ip]) {
|
||||||
|
failedIPsCache[ip] = {
|
||||||
|
attempts: failedIPsCache[ip].attempts + 1,
|
||||||
|
lastAttempt: now
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
failedIPsCache[ip] = {
|
||||||
|
attempts: 1,
|
||||||
|
lastAttempt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location data for a single IP using ipapi.co
|
||||||
|
*/
|
||||||
|
async function fetchIpLocation(ip: string): Promise<IpLocationData | null> {
|
||||||
|
try {
|
||||||
|
// Skip this IP if it has failed too many times
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server cache first
|
||||||
|
const now = Date.now();
|
||||||
|
if (serverCache[ip] && (now - serverCache[ip].timestamp) < CACHE_EXPIRATION) {
|
||||||
|
return serverCache[ip];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay to avoid rate limiting (100 requests per minute max)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600)); // ~100 req/min = 1 req per 600ms
|
||||||
|
|
||||||
|
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 status if successful
|
||||||
|
if (failedIPsCache[ip]) {
|
||||||
|
delete failedIPsCache[ip];
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
serverCache[ip] = locationData;
|
||||||
|
|
||||||
|
return locationData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching location for IP ${ip}:`, error);
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route handler for batch IP location lookups
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { ips } = await request.json();
|
||||||
|
|
||||||
|
if (!ips || !Array.isArray(ips) || ips.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid or empty IP list'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit batch size to 50 IPs to prevent abuse
|
||||||
|
const ipList = ips.slice(0, 50);
|
||||||
|
const results: Record<string, IpLocationData | null> = {};
|
||||||
|
|
||||||
|
// Filter out IPs that should be skipped
|
||||||
|
const validIPs = ipList.filter(ip => {
|
||||||
|
if (typeof ip !== 'string' || !ip.trim()) return false;
|
||||||
|
if (isPrivateIP(ip)) {
|
||||||
|
results[ip] = getPrivateIPData(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
|
||||||
|
results[ip] = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process remaining IPs sequentially to respect rate limits
|
||||||
|
for (const ip of validIPs) {
|
||||||
|
results[ip] = await fetchIpLocation(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<Record<string, IpLocationData | null>> = {
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Batch IP lookup error:', error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP is a private/local address
|
||||||
|
*/
|
||||||
|
function 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate location data for private IP addresses
|
||||||
|
*/
|
||||||
|
function getPrivateIPData(ip: string): IpLocationData {
|
||||||
|
return {
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { GeoData } from '@/app/api/types';
|
import { GeoData } from '@/app/api/types';
|
||||||
|
import { getLocationsFromIPs } from '@/app/utils/ipLocation';
|
||||||
|
|
||||||
interface GeoAnalyticsProps {
|
interface GeoAnalyticsProps {
|
||||||
data: GeoData[];
|
data: GeoData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interface for IP location data in our cache
|
||||||
|
interface IpLocationDetail {
|
||||||
|
country: string;
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
continent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for IP location data
|
||||||
|
interface LocationCache {
|
||||||
|
[key: string]: IpLocationDetail;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
||||||
const [viewMode, setViewMode] = useState<'country' | 'city' | 'region' | 'continent'>('country');
|
const [viewMode, setViewMode] = useState<'country' | 'city' | 'region' | 'continent'>('country');
|
||||||
|
const [locationCache, setLocationCache] = useState<LocationCache>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
// Track IPs that failed to resolve
|
||||||
|
const [failedIPs, setFailedIPs] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 安全地格式化数字
|
// 安全地格式化数字
|
||||||
const formatNumber = (value: number | undefined | null): string => {
|
const formatNumber = (value: number | undefined | null): string => {
|
||||||
@@ -27,7 +45,122 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
// Handle tab selection - only change local view mode
|
// Handle tab selection - only change local view mode
|
||||||
const handleViewModeChange = (mode: 'country' | 'city' | 'region' | 'continent') => {
|
const handleViewModeChange = (mode: 'country' | 'city' | 'region' | 'continent') => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
// Only change the local view mode, no callback to parent
|
};
|
||||||
|
|
||||||
|
// Load location data for all IPs when the data changes
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLocations = async () => {
|
||||||
|
if (sortedData.length === 0) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const tempCache: LocationCache = {...locationCache};
|
||||||
|
const tempFailedIPs = new Set(failedIPs);
|
||||||
|
|
||||||
|
// Get all unique IPs that aren't already in the cache and haven't failed
|
||||||
|
const uniqueIPs = [...new Set(sortedData.map(item => item.location))].filter(ip =>
|
||||||
|
ip &&
|
||||||
|
ip !== 'Unknown' &&
|
||||||
|
!tempCache[ip] &&
|
||||||
|
!tempFailedIPs.has(ip)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueIPs.length === 0) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use batch lookup for better performance
|
||||||
|
const batchResults = await getLocationsFromIPs(uniqueIPs);
|
||||||
|
|
||||||
|
// Convert results to our cache format
|
||||||
|
for (const [ip, data] of Object.entries(batchResults)) {
|
||||||
|
if (data) {
|
||||||
|
tempCache[ip] = {
|
||||||
|
country: data.country_name,
|
||||||
|
city: data.city,
|
||||||
|
region: data.region,
|
||||||
|
continent: data.continent_name
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Mark as failed
|
||||||
|
tempFailedIPs.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocationCache(tempCache);
|
||||||
|
setFailedIPs(tempFailedIPs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching location data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLocations();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Get the appropriate location value based on the current view mode
|
||||||
|
const getLocationValue = (item: GeoData): string => {
|
||||||
|
const ip = item.location || '';
|
||||||
|
|
||||||
|
// If there's no IP or it's "Unknown", return that value
|
||||||
|
if (!ip || ip === 'Unknown') return 'Unknown';
|
||||||
|
|
||||||
|
// If IP failed to resolve, return Unknown
|
||||||
|
if (failedIPs.has(ip)) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return from cache if available
|
||||||
|
if (locationCache[ip]) {
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'country':
|
||||||
|
return locationCache[ip].country || 'Unknown';
|
||||||
|
case 'city':
|
||||||
|
return locationCache[ip].city || 'Unknown';
|
||||||
|
case 'region':
|
||||||
|
return locationCache[ip].region || 'Unknown';
|
||||||
|
case 'continent':
|
||||||
|
return locationCache[ip].continent || 'Unknown';
|
||||||
|
default:
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return placeholder if not in cache yet
|
||||||
|
return `Loading...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the appropriate area value based on the current view mode
|
||||||
|
const getAreaValue = (item: GeoData): string => {
|
||||||
|
const ip = item.location || '';
|
||||||
|
|
||||||
|
// If there's no IP or it's "Unknown", return empty string
|
||||||
|
if (!ip || ip === 'Unknown' || failedIPs.has(ip)) return '';
|
||||||
|
|
||||||
|
// Return from cache if available
|
||||||
|
if (locationCache[ip]) {
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'country':
|
||||||
|
// For country view, show the continent as area
|
||||||
|
return locationCache[ip].continent || '';
|
||||||
|
case 'city':
|
||||||
|
// For city view, show the country and region
|
||||||
|
return `${locationCache[ip].country}, ${locationCache[ip].region}`;
|
||||||
|
case 'region':
|
||||||
|
// For region view, show the country
|
||||||
|
return locationCache[ip].country || '';
|
||||||
|
case 'continent':
|
||||||
|
// For continent view, no additional area needed
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty if not in cache yet
|
||||||
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +193,14 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center items-center py-2 mb-4">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
|
||||||
|
<span className="text-sm text-gray-500">Loading location data...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table with added area column */}
|
{/* Table with added area column */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
@@ -71,9 +212,9 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
viewMode === 'region' ? 'Region' : 'Continent'}
|
viewMode === 'region' ? 'Region' : 'Continent'}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{viewMode === 'country' ? 'Countries' :
|
{viewMode === 'country' ? 'Continent' :
|
||||||
viewMode === 'city' ? 'Cities' :
|
viewMode === 'city' ? 'Location' :
|
||||||
viewMode === 'region' ? 'Regions' : 'Continents'}
|
viewMode === 'region' ? 'Country' : 'Area'}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Visits
|
Visits
|
||||||
@@ -91,10 +232,13 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
sortedData.map((item, index) => (
|
sortedData.map((item, index) => (
|
||||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{item.location || 'Unknown'}
|
{getLocationValue(item)}
|
||||||
|
{item.location && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{item.location}</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{item.area || ''}
|
{getAreaValue(item)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{formatNumber(item.visits)}
|
{formatNumber(item.visits)}
|
||||||
|
|||||||
100
app/components/ipLocationTest.tsx
Normal file
100
app/components/ipLocationTest.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getLocationFromIP } from '@/app/utils/ipLocation';
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
ip: string;
|
||||||
|
country_name: string;
|
||||||
|
country_code: string;
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
continent_code: string;
|
||||||
|
continent_name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IpLocationTest() {
|
||||||
|
const [locationData, setLocationData] = useState<LocationData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const testIp = "120.244.39.90";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLocation() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const data = await getLocationFromIP(testIp);
|
||||||
|
setLocationData(data);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLocation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">IP Location Test: {testIp}</h2>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center text-gray-500">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
|
||||||
|
Loading location data...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && locationData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Location Data:</h3>
|
||||||
|
<pre className="mt-2 p-4 bg-gray-100 rounded overflow-auto">
|
||||||
|
{JSON.stringify(locationData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">Country</h4>
|
||||||
|
<div>{locationData.country_name} ({locationData.country_code})</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">City</h4>
|
||||||
|
<div>{locationData.city || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">Region</h4>
|
||||||
|
<div>{locationData.region || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">Continent</h4>
|
||||||
|
<div>{locationData.continent_name} ({locationData.continent_code})</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded col-span-2">
|
||||||
|
<h4 className="font-medium">Coordinates</h4>
|
||||||
|
<div>Latitude: {locationData.latitude}, Longitude: {locationData.longitude}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/ip-test/page.tsx
Normal file
10
app/ip-test/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import IpLocationTest from '../components/ipLocationTest';
|
||||||
|
|
||||||
|
export default function IpTestPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4 max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">IP to Location Test</h1>
|
||||||
|
<IpLocationTest />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
484
app/utils/ipLocation.ts
Normal file
484
app/utils/ipLocation.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user