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
+5
View File
@@ -46,6 +46,11 @@ IDENTITY_API_KEY_LINK=
LINK_PRICE_PER_LINK_GHS=0.05 LINK_PRICE_PER_LINK_GHS=0.05
# Country analytics: MaxMind GeoLite2 (free account at https://www.maxmind.com/en/geolite2/signup)
MAXMIND_ACCOUNT_ID=
MAXMIND_LICENSE_KEY=
LINK_GEOIP_DATABASE=
AFIA_ENABLED=true AFIA_ENABLED=true
AFIA_PRODUCT=link AFIA_PRODUCT=link
AFIA_PROVIDER=openai AFIA_PROVIDER=openai
+1
View File
@@ -18,6 +18,7 @@
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/storage/app/geoip
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
+20
View File
@@ -53,3 +53,23 @@ php artisan migrate --force
``` ```
Database name: `ladill_link`. Database name: `ladill_link`.
## Country analytics (GeoIP)
Click countries are resolved from CDN headers when present (`CF-IPCountry`, etc.), then from the visitor IP using a local MaxMind GeoLite2 Country database.
1. Create a free MaxMind account and generate a license key.
2. Set in `.env`:
```env
MAXMIND_ACCOUNT_ID=
MAXMIND_LICENSE_KEY=
```
3. Download the database:
```bash
php artisan link:geoip-update
```
The file is stored at `storage/app/geoip/GeoLite2-Country.mmdb` by default. The command is scheduled weekly via the app scheduler — ensure cron runs `php artisan schedule:run` for link.ladill.com.
@@ -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);
}
}
}
+82 -11
View File
@@ -2,25 +2,46 @@
namespace App\Support\Link; namespace App\Support\Link;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final class LinkGeoResolver 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 public static function countryCode(Request $request): ?string
{ {
foreach (['CF-IPCountry', 'X-Country-Code', 'CloudFront-Viewer-Country'] as $header) { if ($fromHeader = self::countryFromHeaders($request)) {
$value = $request->header($header); return $fromHeader;
if (! is_string($value) || strlen($value) !== 2) {
continue;
}
$code = strtoupper($value);
if (! in_array($code, ['XX', 'T1'], true)) {
return $code;
}
} }
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 public static function countryLabel(?string $code): string
@@ -37,4 +58,54 @@ final class LinkGeoResolver
return $code; 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;
}
}
} }
+1
View File
@@ -21,6 +21,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
'redirect' => $request->fullUrl(), 'redirect' => $request->fullUrl(),
])); ]));
$middleware->trustProxies(at: '*');
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\InjectBootSplash::class, \App\Http\Middleware\InjectBootSplash::class,
\App\Http\Middleware\SetActingAccount::class, \App\Http\Middleware\SetActingAccount::class,
+1
View File
@@ -11,6 +11,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"chillerlan/php-qrcode": "^5.0", "chillerlan/php-qrcode": "^5.0",
"geoip2/geoip2": "^3.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
Generated
+246 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "529339feb28ae432869697327a78cb16", "content-hash": "7cc714889623546800e1242a29cede63",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -297,6 +297,78 @@
], ],
"time": "2026-03-20T21:10:52+00:00" "time": "2026-03-20T21:10:52+00:00"
}, },
{
"name": "composer/ca-bundle",
"version": "1.5.12",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
"reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
"reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-pcre": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8 || ^9",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\CaBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
"keywords": [
"cabundle",
"cacert",
"certificate",
"ssl",
"tls"
],
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
"source": "https://github.com/composer/ca-bundle/tree/1.5.12"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-05-19T11:26:22+00:00"
},
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@@ -741,6 +813,64 @@
], ],
"time": "2025-12-03T09:33:47+00:00" "time": "2025-12-03T09:33:47+00:00"
}, },
{
"name": "geoip2/geoip2",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/GeoIP2-php.git",
"reference": "49fceddd694295e76e970a32848e03bb19e56b42"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/49fceddd694295e76e970a32848e03bb19e56b42",
"reference": "49fceddd694295e76e970a32848e03bb19e56b42",
"shasum": ""
},
"require": {
"ext-json": "*",
"maxmind-db/reader": "^1.13.0",
"maxmind/web-service-common": "~0.11",
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "4.*"
},
"type": "library",
"autoload": {
"psr-4": {
"GeoIp2\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory J. Oschwald",
"email": "goschwald@maxmind.com",
"homepage": "https://www.maxmind.com/"
}
],
"description": "MaxMind GeoIP2 PHP API",
"homepage": "https://github.com/maxmind/GeoIP2-php",
"keywords": [
"IP",
"geoip",
"geoip2",
"geolocation",
"maxmind"
],
"support": {
"issues": "https://github.com/maxmind/GeoIP2-php/issues",
"source": "https://github.com/maxmind/GeoIP2-php/tree/v3.3.0"
},
"time": "2025-11-20T18:50:15+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",
@@ -2250,6 +2380,121 @@
], ],
"time": "2026-03-08T20:05:35+00:00" "time": "2026-03-08T20:05:35+00:00"
}, },
{
"name": "maxmind-db/reader",
"version": "v1.13.1",
"source": {
"type": "git",
"url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git",
"reference": "2194f58d0f024ce923e685cdf92af3daf9951908"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/2194f58d0f024ce923e685cdf92af3daf9951908",
"reference": "2194f58d0f024ce923e685cdf92af3daf9951908",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"conflict": {
"ext-maxminddb": "<1.11.1 || >=2.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": ">=8.0.0,<10.0.0",
"squizlabs/php_codesniffer": "4.*"
},
"suggest": {
"ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
"ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
"ext-maxminddb": "A C-based database decoder that provides significantly faster lookups",
"maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)"
},
"type": "library",
"autoload": {
"psr-4": {
"MaxMind\\Db\\": "src/MaxMind/Db"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory J. Oschwald",
"email": "goschwald@maxmind.com",
"homepage": "https://www.maxmind.com/"
}
],
"description": "MaxMind DB Reader API",
"homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php",
"keywords": [
"database",
"geoip",
"geoip2",
"geolocation",
"maxmind"
],
"support": {
"issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues",
"source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.13.1"
},
"time": "2025-11-21T22:24:26+00:00"
},
{
"name": "maxmind/web-service-common",
"version": "v0.11.1",
"source": {
"type": "git",
"url": "https://github.com/maxmind/web-service-common-php.git",
"reference": "c309236b5a5555b96cf560089ec3cead12d845d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/c309236b5a5555b96cf560089ec3cead12d845d2",
"reference": "c309236b5a5555b96cf560089ec3cead12d845d2",
"shasum": ""
},
"require": {
"composer/ca-bundle": "^1.0.3",
"ext-curl": "*",
"ext-json": "*",
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "4.*"
},
"type": "library",
"autoload": {
"psr-4": {
"MaxMind\\Exception\\": "src/Exception",
"MaxMind\\WebService\\": "src/WebService"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory Oschwald",
"email": "goschwald@maxmind.com"
}
],
"description": "Internal MaxMind Web Service API",
"homepage": "https://github.com/maxmind/web-service-common-php",
"support": {
"issues": "https://github.com/maxmind/web-service-common-php/issues",
"source": "https://github.com/maxmind/web-service-common-php/tree/v0.11.1"
},
"time": "2026-01-13T17:56:03+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
+3
View File
@@ -5,4 +5,7 @@ return [
'slug_length' => (int) env('LINK_SLUG_LENGTH', 8), 'slug_length' => (int) env('LINK_SLUG_LENGTH', 8),
'click_unique_window_hours' => (int) env('LINK_CLICK_UNIQUE_WINDOW_HOURS', 24), 'click_unique_window_hours' => (int) env('LINK_CLICK_UNIQUE_WINDOW_HOURS', 24),
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'), 'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
'geoip_database' => env('LINK_GEOIP_DATABASE', storage_path('app/geoip/GeoLite2-Country.mmdb')),
'maxmind_account_id' => env('MAXMIND_ACCOUNT_ID'),
'maxmind_license_key' => env('MAXMIND_LICENSE_KEY'),
]; ];
+1
View File
@@ -15,3 +15,4 @@ Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlappi
Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping(); Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping();
Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping(); Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping();
Schedule::command('ssl:renew')->daily()->withoutOverlapping(); Schedule::command('ssl:renew')->daily()->withoutOverlapping();
Schedule::command('link:geoip-update')->weekly()->withoutOverlapping();
+26
View File
@@ -25,6 +25,8 @@ class LinkAnalyticsTest extends TestCase
public function test_redirect_records_device_browser_os_and_country(): void public function test_redirect_records_device_browser_os_and_country(): void
{ {
config(['link.geoip_database' => base_path('tests/fixtures/GeoLite2-Country-Test.mmdb')]);
$user = $this->user(); $user = $this->user();
ShortLink::create([ ShortLink::create([
'user_id' => $user->id, 'user_id' => $user->id,
@@ -50,6 +52,30 @@ class LinkAnalyticsTest extends TestCase
$this->assertSame('GH', $click->country); $this->assertSame('GH', $click->country);
} }
public function test_redirect_records_country_from_ip_when_proxy_header_missing(): void
{
config(['link.geoip_database' => base_path('tests/fixtures/GeoLite2-Country-Test.mmdb')]);
$user = $this->user();
ShortLink::create([
'user_id' => $user->id,
'slug' => 'geo-ip',
'source_app' => 'link',
'source_kind' => 'redirect',
'is_managed_here' => true,
'destination_url' => 'https://example.com/target',
'is_active' => true,
]);
$this->withServerVariables(['REMOTE_ADDR' => '81.2.69.142'])
->withHeaders([
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
])->get('https://ladl.link/geo-ip')
->assertRedirect('https://example.com/target');
$this->assertSame('GB', LinkClick::first()?->country);
}
public function test_breakdown_for_link_groups_clicks_by_device(): void public function test_breakdown_for_link_groups_clicks_by_device(): void
{ {
$user = $this->user(); $user = $this->user();
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Tests\Unit;
use App\Support\Link\LinkGeoResolver;
use Illuminate\Http\Request;
use Tests\TestCase;
class LinkGeoResolverTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
LinkGeoResolver::resetReader();
config(['link.geoip_database' => base_path('tests/fixtures/GeoLite2-Country-Test.mmdb')]);
}
protected function tearDown(): void
{
LinkGeoResolver::resetReader();
parent::tearDown();
}
public function test_prefers_proxy_country_header_over_ip_lookup(): void
{
$request = Request::create('/', 'GET', server: [
'REMOTE_ADDR' => '81.2.69.142',
'HTTP_CF_IPCOUNTRY' => 'GH',
]);
$this->assertSame('GH', LinkGeoResolver::countryCode($request));
}
public function test_resolves_country_from_public_ip_when_header_missing(): void
{
$request = Request::create('/', 'GET', server: [
'REMOTE_ADDR' => '81.2.69.142',
]);
$this->assertSame('GB', LinkGeoResolver::countryCode($request));
}
public function test_ignores_private_ips(): void
{
$this->assertNull(LinkGeoResolver::countryFromIp('127.0.0.1'));
$this->assertNull(LinkGeoResolver::countryFromIp('10.0.0.4'));
}
public function test_returns_null_when_geoip_database_is_missing(): void
{
config(['link.geoip_database' => storage_path('app/geoip/missing.mmdb')]);
LinkGeoResolver::resetReader();
$request = Request::create('/', 'GET', server: [
'REMOTE_ADDR' => '81.2.69.142',
]);
$this->assertNull(LinkGeoResolver::countryCode($request));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB