diff --git a/app/app/Services/Tracking/GeoResolver.php b/app/app/Services/Tracking/GeoResolver.php new file mode 100644 index 00000000..1a6cf55f --- /dev/null +++ b/app/app/Services/Tracking/GeoResolver.php @@ -0,0 +1,70 @@ + null, 'region' => null, 'asn_org' => null, 'is_datacenter' => false]; + + $cityDb = (string) config('services.geoip.city_db'); + $asnDb = (string) config('services.geoip.asn_db'); + + $city = null; + $region = null; + $org = null; + + try { + if ($cityDb !== '' && is_readable($cityDb)) { + $rec = (new Reader($cityDb))->city($ip); + $city = $rec->city->name; + $region = $rec->mostSpecificSubdivision->name; + } + if ($asnDb !== '' && is_readable($asnDb)) { + $org = (new Reader($asnDb))->asn($ip)->autonomousSystemOrganization; + } + } catch (\Throwable) { + return $empty; // приватный IP, не найден, битая база — учёт не ломаем + } + + return [ + 'city' => $city, + 'region' => $region, + 'asn_org' => $org, + 'is_datacenter' => self::looksLikeDatacenter($org), + ]; + } + + public static function looksLikeDatacenter(?string $org): bool + { + $o = mb_strtolower((string) $org); + if ($o === '') { + return false; + } + foreach (self::DATACENTER_MARKERS as $m) { + if (str_contains($o, $m)) { + return true; + } + } + + return false; + } +} diff --git a/app/composer.json b/app/composer.json index 883e7469..fec8d207 100644 --- a/app/composer.json +++ b/app/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "barryvdh/laravel-dompdf": "^3.1", + "geoip2/geoip2": "^3.3", "laravel/framework": "^13.7", "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", diff --git a/app/composer.lock b/app/composer.lock index 99523822..c4278a6e 100644 --- a/app/composer.lock +++ b/app/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": "da84c833d162bd54a2eff0f338eead8a", + "content-hash": "614b73070bef10a02153aeebf5ce1281", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -212,6 +212,78 @@ ], "time": "2024-02-09T16:56:22+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": "composer/pcre", "version": "3.3.2", @@ -890,6 +962,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", @@ -2656,6 +2786,121 @@ }, "time": "2026-06-23T18:43:15+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/app/config/services.php b/app/config/services.php index 754abed4..52ac21ad 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -152,6 +152,13 @@ return [ 'counter_id' => env('METRIKA_COUNTER_ID'), ], + // Учёт посетителей: офлайн-базы гео (DB-IP Lite). Файлов нет → город не определяется, + // учёт продолжает работать. Обновление баз — раз в месяц, вручную. + 'geoip' => [ + 'city_db' => env('GEOIP_CITY_DB', storage_path('geoip/dbip-city-lite.mmdb')), + 'asn_db' => env('GEOIP_ASN_DB', storage_path('geoip/dbip-asn-lite.mmdb')), + ], + // Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth // на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить // опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25, diff --git a/app/tests/Unit/GeoResolverTest.php b/app/tests/Unit/GeoResolverTest.php new file mode 100644 index 00000000..b4c9355b --- /dev/null +++ b/app/tests/Unit/GeoResolverTest.php @@ -0,0 +1,28 @@ +set('services.geoip.city_db', '/nope/city.mmdb'); + config()->set('services.geoip.asn_db', '/nope/asn.mmdb'); + + $res = (new GeoResolver())->lookup('8.8.8.8'); + + expect($res)->toMatchArray([ + 'city' => null, 'region' => null, 'asn_org' => null, 'is_datacenter' => false, + ]); +}); + +test('организация-хостинг помечается как дата-центр/VPN', function () { + expect(GeoResolver::looksLikeDatacenter('Tencent Cloud Computing'))->toBeTrue(); + expect(GeoResolver::looksLikeDatacenter('DigitalOcean, LLC'))->toBeTrue(); + expect(GeoResolver::looksLikeDatacenter('NordVPN'))->toBeTrue(); + expect(GeoResolver::looksLikeDatacenter('Tele2 Russia'))->toBeFalse(); + expect(GeoResolver::looksLikeDatacenter('Rostelecom'))->toBeFalse(); + expect(GeoResolver::looksLikeDatacenter(null))->toBeFalse(); +});