Add GeoIP country fallback for link click analytics.
Deploy Ladill Link / deploy (push) Successful in 39s

Resolve countries from CDN headers first, then MaxMind GeoLite2 via visitor IP, with an artisan command to download and refresh the database.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 23:36:01 +00:00
co-authored by Cursor
parent af0bdf9081
commit 344bf76391
13 changed files with 532 additions and 12 deletions
+82 -11
View File
@@ -2,25 +2,46 @@
namespace App\Support\Link;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use Illuminate\Http\Request;
final class LinkGeoResolver
{
/** @var list<string> */
private const HEADER_KEYS = ['CF-IPCountry', 'X-Country-Code', 'CloudFront-Viewer-Country'];
private static ?Reader $reader = null;
public static function countryCode(Request $request): ?string
{
foreach (['CF-IPCountry', 'X-Country-Code', 'CloudFront-Viewer-Country'] as $header) {
$value = $request->header($header);
if (! is_string($value) || strlen($value) !== 2) {
continue;
}
$code = strtoupper($value);
if (! in_array($code, ['XX', 'T1'], true)) {
return $code;
}
if ($fromHeader = self::countryFromHeaders($request)) {
return $fromHeader;
}
return null;
return self::countryFromIp($request->ip());
}
public static function countryFromIp(?string $ip): ?string
{
if (! self::isPublicIp($ip)) {
return null;
}
$reader = self::reader();
if ($reader === null) {
return null;
}
try {
$code = strtoupper((string) ($reader->country((string) $ip)->country->isoCode ?? ''));
return strlen($code) === 2 && ! in_array($code, ['XX', 'T1'], true) ? $code : null;
} catch (AddressNotFoundException) {
return null;
} catch (\Throwable) {
return null;
}
}
public static function countryLabel(?string $code): string
@@ -37,4 +58,54 @@ final class LinkGeoResolver
return $code;
}
/** Visible for tests. */
public static function resetReader(): void
{
self::$reader = null;
}
private static function countryFromHeaders(Request $request): ?string
{
foreach (self::HEADER_KEYS as $header) {
$value = $request->header($header);
if (! is_string($value) || strlen($value) !== 2) {
continue;
}
$code = strtoupper($value);
if (! in_array($code, ['XX', 'T1'], true)) {
return $code;
}
}
return null;
}
private static function isPublicIp(?string $ip): bool
{
return is_string($ip)
&& $ip !== ''
&& filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
private static function reader(): ?Reader
{
if (self::$reader !== null) {
return self::$reader;
}
$path = (string) config('link.geoip_database', '');
if ($path === '' || ! is_readable($path)) {
return null;
}
try {
self::$reader = new Reader($path);
return self::$reader;
} catch (\Throwable) {
return null;
}
}
}