self::ipapiCo($ip), 'ipinfo' => self::ipinfo($ip), 'ip_api_com' => self::ipApiCom($ip), default => null, }; } catch (Throwable $e) { Log::warn('GeoIP provider failed', ['provider' => $provider, 'error' => $e->getMessage()]); } Db::cacheSet($cacheKey, json_encode($result ?? []), Config::int('GEOIP_CACHE_TTL', 3600)); return $result; } private static function fromHeaders(): ?array { $lat = $_SERVER['HTTP_CF_IPLATITUDE'] ?? null; $lng = $_SERVER['HTTP_CF_IPLONGITUDE'] ?? null; if ($lat === null || $lng === null) { return null; } return self::result((float)$lat, (float)$lng, $_SERVER['HTTP_CF_IPCITY'] ?? null, $_SERVER['HTTP_CF_REGION_CODE'] ?? null); } private static function ipapiCo(string $ip): ?array { $key = Config::get('GEOIP_API_KEY'); $base = rtrim(Config::get('GEOIP_BASE_URL_OVERRIDE') ?: 'https://ipapi.co', '/'); // override is for the test-suite only $url = "$base/" . rawurlencode($ip) . "/json/" . ($key ? "?key=" . rawurlencode($key) : ''); $r = Http::request('GET', $url, null, ['Accept' => 'application/json'], 3); $j = $r['json']; if ($r['status'] !== 200 || !is_array($j) || !isset($j['latitude'], $j['longitude'])) { return null; } return self::result((float)$j['latitude'], (float)$j['longitude'], $j['city'] ?? null, $j['region_code'] ?? null); } private static function ipinfo(string $ip): ?array { $key = Config::require('GEOIP_API_KEY'); $r = Http::request('GET', "https://ipinfo.io/" . rawurlencode($ip) . "/json?token=" . rawurlencode($key), null, ['Accept' => 'application/json'], 3); $j = $r['json']; if ($r['status'] !== 200 || !is_array($j) || empty($j['loc'])) { return null; } [$lat, $lng] = array_map('floatval', explode(',', (string)$j['loc']) + [0, 0]); return self::result($lat, $lng, $j['city'] ?? null, $j['region'] ?? null); } private static function ipApiCom(string $ip): ?array { $key = Config::get('GEOIP_API_KEY'); $base = $key ? "https://pro.ip-api.com/json/" : "http://ip-api.com/json/"; $r = Http::request('GET', $base . rawurlencode($ip) . "?fields=status,lat,lon,city,region" . ($key ? "&key=" . rawurlencode($key) : ''), null, [], 3); $j = $r['json']; if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'success') { return null; } return self::result((float)$j['lat'], (float)$j['lon'], $j['city'] ?? null, $j['region'] ?? null); } private static function result(float $lat, float $lng, ?string $city, ?string $region): ?array { if ($lat === 0.0 && $lng === 0.0 || abs($lat) > 90 || abs($lng) > 180) { return null; } // Round to ~1 km so we never hold anything close to a precise location. return ['lat' => round($lat, 2), 'lng' => round($lng, 2), 'city' => $city ? Validator::clean($city, 60) : null, 'region' => $region ? Validator::clean($region, 20) : null]; } public static function isPrivate(string $ip): bool { return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false; } }