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
+26
View File
@@ -25,6 +25,8 @@ class LinkAnalyticsTest extends TestCase
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();
ShortLink::create([
'user_id' => $user->id,
@@ -50,6 +52,30 @@ class LinkAnalyticsTest extends TestCase
$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
{
$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