110 lines
4.8 KiB
PHP
110 lines
4.8 KiB
PHP
<?php
|
||||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Privacy-conscious approximate IP geolocation for the "closest campus"
|
|||
|
|
* recommendation. Results are city-level, cached briefly by hashed IP, and
|
|||
|
|
* never stored with a lead.
|
|||
|
|
*
|
|||
|
|
* GEOIP_PROVIDER:
|
|||
|
|
* none – disabled (visitor always chooses manually)
|
|||
|
|
* header – trust a CDN header (Cloudflare: CF-IPCountry/CF-IPLatitude/CF-IPLongitude)
|
|||
|
|
* ipapi_co – https://ipapi.co (no key needed for low volume; GEOIP_API_KEY optional)
|
|||
|
|
* ipinfo – https://ipinfo.io (GEOIP_API_KEY required)
|
|||
|
|
* ip_api_com – http(s)://ip-api.com (GEOIP_API_KEY required for HTTPS/commercial use)
|
|||
|
|
*/
|
|||
|
|
final class GeoIp
|
|||
|
|
{
|
|||
|
|
/** @return array{lat:float,lng:float,city:?string,region:?string}|null */
|
|||
|
|
public static function locate(string $ip): ?array
|
|||
|
|
{
|
|||
|
|
$provider = strtolower(Config::get('GEOIP_PROVIDER', 'ipapi_co') ?? 'none');
|
|||
|
|
if ($provider === 'none' || self::isPrivate($ip)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
if ($provider === 'header') {
|
|||
|
|
return self::fromHeaders();
|
|||
|
|
}
|
|||
|
|
$cacheKey = 'geoip:' . substr(hash('sha256', $ip . (Config::get('HASH_SALT') ?? '')), 0, 40);
|
|||
|
|
$cached = Db::cacheGet($cacheKey);
|
|||
|
|
if ($cached !== null) {
|
|||
|
|
$c = json_decode($cached, true);
|
|||
|
|
return is_array($c) && $c ? $c : null;
|
|||
|
|
}
|
|||
|
|
$result = null;
|
|||
|
|
try {
|
|||
|
|
$result = match ($provider) {
|
|||
|
|
'ipapi_co' => 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;
|
|||
|
|
}
|
|||
|
|
}
|