Add GeoIP country fallback for link click analytics.
Deploy Ladill Link / deploy (push) Successful in 39s
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:
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
class UpdateGeoIpDatabaseCommand extends Command
|
||||
{
|
||||
protected $signature = 'link:geoip-update';
|
||||
|
||||
protected $description = 'Download the MaxMind GeoLite2 Country database for link click geolocation';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$accountId = (string) config('link.maxmind_account_id', '');
|
||||
$licenseKey = (string) config('link.maxmind_license_key', '');
|
||||
|
||||
if ($accountId === '' || $licenseKey === '') {
|
||||
$this->error('Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env before updating GeoIP data.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targetDir = dirname((string) config('link.geoip_database'));
|
||||
File::ensureDirectoryExists($targetDir);
|
||||
|
||||
$this->info('Downloading GeoLite2-Country database from MaxMind…');
|
||||
|
||||
$response = Http::withBasicAuth($accountId, $licenseKey)
|
||||
->timeout(180)
|
||||
->withOptions(['allow_redirects' => true])
|
||||
->get('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download', [
|
||||
'suffix' => 'tar.gz',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->error('MaxMind download failed: HTTP '.$response->status());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$archivePath = $targetDir.'/GeoLite2-Country.tar.gz';
|
||||
$extractDir = $targetDir.'/extract-'.uniqid('', true);
|
||||
file_put_contents($archivePath, $response->body());
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($extractDir);
|
||||
|
||||
$process = new \Symfony\Component\Process\Process([
|
||||
'tar', '-xzf', $archivePath, '-C', $extractDir,
|
||||
]);
|
||||
$process->mustRun();
|
||||
|
||||
$finder = Finder::create()
|
||||
->files()
|
||||
->name('GeoLite2-Country.mmdb')
|
||||
->in($extractDir);
|
||||
|
||||
$database = null;
|
||||
foreach ($finder as $file) {
|
||||
$database = $file->getPathname();
|
||||
break;
|
||||
}
|
||||
|
||||
if ($database === null) {
|
||||
$this->error('GeoLite2-Country.mmdb was not found in the downloaded archive.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$target = (string) config('link.geoip_database');
|
||||
File::copy($database, $target);
|
||||
$this->info('GeoIP database updated at '.$target);
|
||||
|
||||
return self::SUCCESS;
|
||||
} finally {
|
||||
@unlink($archivePath);
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user