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:
@@ -46,6 +46,11 @@ IDENTITY_API_KEY_LINK=
|
||||
|
||||
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_PRODUCT=link
|
||||
AFIA_PROVIDER=openai
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/app/geoip
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
@@ -53,3 +53,23 @@ php artisan migrate --force
|
||||
```
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
|
||||
'redirect' => $request->fullUrl(),
|
||||
]));
|
||||
$middleware->trustProxies(at: '*');
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\InjectBootSplash::class,
|
||||
\App\Http\Middleware\SetActingAccount::class,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"chillerlan/php-qrcode": "^5.0",
|
||||
"geoip2/geoip2": "^3.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
|
||||
Generated
+246
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "529339feb28ae432869697327a78cb16",
|
||||
"content-hash": "7cc714889623546800e1242a29cede63",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -297,6 +297,78 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v3.0.3",
|
||||
@@ -741,6 +813,64 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v1.1.4",
|
||||
@@ -2250,6 +2380,121 @@
|
||||
],
|
||||
"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",
|
||||
"version": "3.10.0",
|
||||
|
||||
@@ -5,4 +5,7 @@ return [
|
||||
'slug_length' => (int) env('LINK_SLUG_LENGTH', 8),
|
||||
'click_unique_window_hours' => (int) env('LINK_CLICK_UNIQUE_WINDOW_HOURS', 24),
|
||||
'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'),
|
||||
];
|
||||
|
||||
@@ -15,3 +15,4 @@ Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlappi
|
||||
Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping();
|
||||
Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping();
|
||||
Schedule::command('ssl:renew')->daily()->withoutOverlapping();
|
||||
Schedule::command('link:geoip-update')->weekly()->withoutOverlapping();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Reference in New Issue
Block a user