diff --git a/.env.example b/.env.example index 169cd5a..9aee96e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index b71b1ea..1a7953c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /public/storage /storage/*.key /storage/pail +/storage/app/geoip /vendor Homestead.json Homestead.yaml diff --git a/DEPLOY.md b/DEPLOY.md index 977c7ca..ae5a8d6 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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. diff --git a/app/Console/Commands/UpdateGeoIpDatabaseCommand.php b/app/Console/Commands/UpdateGeoIpDatabaseCommand.php new file mode 100644 index 0000000..62a8f20 --- /dev/null +++ b/app/Console/Commands/UpdateGeoIpDatabaseCommand.php @@ -0,0 +1,84 @@ +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); + } + } +} diff --git a/app/Support/Link/LinkGeoResolver.php b/app/Support/Link/LinkGeoResolver.php index 1944758..caa77e3 100644 --- a/app/Support/Link/LinkGeoResolver.php +++ b/app/Support/Link/LinkGeoResolver.php @@ -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 */ + 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; + } + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c64cf93..7620363 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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, diff --git a/composer.json b/composer.json index f3c1605..5f80cf0 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 1f2b027..c2be17d 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/link.php b/config/link.php index d9cf3a2..28feb71 100644 --- a/config/link.php +++ b/config/link.php @@ -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'), ]; diff --git a/routes/console.php b/routes/console.php index 3aede45..2a7d534 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/tests/Feature/LinkAnalyticsTest.php b/tests/Feature/LinkAnalyticsTest.php index f53ec31..e6baa37 100644 --- a/tests/Feature/LinkAnalyticsTest.php +++ b/tests/Feature/LinkAnalyticsTest.php @@ -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(); diff --git a/tests/Unit/LinkGeoResolverTest.php b/tests/Unit/LinkGeoResolverTest.php new file mode 100644 index 0000000..8f5a487 --- /dev/null +++ b/tests/Unit/LinkGeoResolverTest.php @@ -0,0 +1,62 @@ + 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)); + } +} diff --git a/tests/fixtures/GeoLite2-Country-Test.mmdb b/tests/fixtures/GeoLite2-Country-Test.mmdb new file mode 100644 index 0000000..f9dda93 Binary files /dev/null and b/tests/fixtures/GeoLite2-Country-Test.mmdb differ