feat(visitors): определение города и оператора по офлайн-базе + признак VPN/хостинга
Гео читается из локального файла (DB-IP Lite) — IP посетителя наружу не уходит. Нет файла базы → город не определяется, учёт продолжает работать (fail-open). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Tracking;
|
||||
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
/**
|
||||
* Город/регион/оператор по IP из ОФЛАЙН-базы (DB-IP Lite). Наружу ничего не шлём —
|
||||
* IP посетителя не покидает сервер. Нет файла базы → пустой результат (fail-open).
|
||||
* Хостинг/VPN определяем по названию организации: у таких гостей город недостоверен.
|
||||
* Spec: docs/superpowers/specs/2026-07-13-visitors-analytics-design.md §9
|
||||
*/
|
||||
class GeoResolver
|
||||
{
|
||||
private const DATACENTER_MARKERS = [
|
||||
'hosting', 'cloud', 'server', 'data center', 'datacenter', 'vpn', 'vps', 'proxy',
|
||||
'digitalocean', 'tencent', 'amazon', 'aws', 'google llc', 'microsoft', 'ovh',
|
||||
'hetzner', 'vultr', 'linode', 'scaleway', 'alibaba', 'oracle', 'colocation', 'm247',
|
||||
];
|
||||
|
||||
/** @return array{city:?string,region:?string,asn_org:?string,is_datacenter:bool} */
|
||||
public function lookup(string $ip): array
|
||||
{
|
||||
$empty = ['city' => 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
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": "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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Tracking\GeoResolver;
|
||||
|
||||
// GeoResolver читает пути к базам через config() → нужен поднятый контейнер приложения.
|
||||
// Юнит-набор его не поднимает, поэтому явно подключаем TestCase (БД не трогаем).
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
test('без гео-базы возвращает пустой результат и не падает', function () {
|
||||
config()->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();
|
||||
});
|
||||
Reference in New Issue
Block a user