Files
Waters ChurchandClaude Fable 5.1 78cd0cc550 Plan Your Visit landing page for visit.waterschurch.org
Next.js 16 static export + PHP 8 API for cPanel/Apache hosting.

- Location-aware campus finder (IP estimate, optional precise location, ZIP search)
- Three-step Plan Your Visit form with separate email/SMS consent
- PHP API: validation, anti-spam, rate limiting, SQLite lead store,
  Mailchimp and Sinch Engage adapters, idempotent retries via cron
- Attribution capture (utm_*, gclid/gbraid/wbraid, fbclid, ttclid, twclid) and
  consent-aware analytics hooks
- Vitest and PHP test suites, cPanel deployment script and docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:22:26 -04:00

110 lines
4.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}