├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── composer.json ├── config └── sxgeo.php ├── docker-compose.yml ├── entrypoint.sh ├── phpunit.xml.dist ├── src ├── Commands │ └── SxGeoUpdate.php ├── DatabaseType.php ├── Facades │ └── SxGeo.php ├── SxGeo.php ├── SxGeoServiceProvider.php └── Updater.php ├── test.py └── tests ├── .assets ├── SxGeoCity.dat └── SxGeoCountry.dat ├── CityDatabaseTest.php ├── CountryDatabaseTest.php ├── FacadeTest.php ├── TestCase.php └── UpdateCommandTest.php /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | .git/ 4 | playground/ 5 | composer.lock 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /vendor/ 3 | /composer.phar 4 | /composer.lock 5 | /phpunit.xml 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | dist: bionic 4 | sudo: false 5 | 6 | cache: 7 | directories: 8 | - $HOME/.composer/cache 9 | 10 | matrix: 11 | fast_finish: true 12 | include: 13 | # Laravel 5.5 14 | - php: 7.1 15 | env: LARAVEL=5.5.* TESTBENCH=3.5.* 16 | - php: 7.2 17 | env: LARAVEL=5.5.* TESTBENCH=3.5.* 18 | 19 | # Laravel 5.6 20 | - php: 7.1 21 | env: LARAVEL=5.6.* TESTBENCH=3.6.* 22 | - php: 7.2 23 | env: LARAVEL=5.6.* TESTBENCH=3.6.* 24 | 25 | # Laravel 6 26 | - php: 7.2 27 | env: LARAVEL=6.* TESTBENCH=4.* 28 | - php: 7.3 29 | env: LARAVEL=6.* TESTBENCH=4.* 30 | - php: 7.4 31 | env: LARAVEL=6.* TESTBENCH=4.* 32 | 33 | # Laravel 7 34 | - php: 7.2 35 | env: LARAVEL=7.* TESTBENCH=5.* 36 | - php: 7.3 37 | env: LARAVEL=7.* TESTBENCH=5.* 38 | - php: 7.4 39 | env: LARAVEL=7.* TESTBENCH=5.* 40 | 41 | # Laravel 8 42 | - php: 8.0 43 | env: LARAVEL=8.* TESTBENCH=6.* 44 | - php: 8.1.0 45 | env: LARAVEL=8.* TESTBENCH=6.* 46 | 47 | # Laravel 9 48 | - php: 8.1.0 49 | env: LARAVEL=9.* TESTBENCH=7.* 50 | 51 | before_install: 52 | - composer self-update --stable --no-interaction 53 | - composer require orchestra/testbench:$TESTBENCH --no-update --no-interaction --dev 54 | 55 | install: 56 | - travis_retry composer install --no-suggest --no-interaction 57 | 58 | script: 59 | - vendor/bin/phpunit --verbose --configuration phpunit.xml.dist 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION 2 | 3 | FROM php:$PHP_VERSION-cli-alpine as base 4 | 5 | RUN apk update && apk add libzip-dev 6 | RUN docker-php-ext-install zip 7 | 8 | WORKDIR /opt/package 9 | 10 | COPY --from=composer:2.1 /usr/bin/composer /usr/bin/composer 11 | COPY . /opt/package/ 12 | 13 | RUN chmod +x /opt/package/entrypoint.sh 14 | ENTRYPOINT /opt/package/entrypoint.sh 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SypexGeo PHP API 2 | 3 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%207.0-8892BF.svg?style=flat)](https://php.net/) 4 | [![Latest stable version](https://poser.pugx.org/eseath/sypexgeo/v/stable)](https://packagist.org/packages/eseath/sypexgeo) 5 | [![Build Status](https://travis-ci.com/Eseath/sypexgeo.svg?branch=master)](https://travis-ci.com/Eseath/sypexgeo) 6 | 7 | A PHP package for working with the [SypexGeo](https://sypexgeo.net) database file. 8 | 9 | The current version supports Laravel 5.1 and later. 10 | 11 | ## Installation 12 | 13 | ``` 14 | composer require eseath/sypexgeo 15 | ``` 16 | 17 | For non-Laravel projects, you need to manually download the database file: 18 | 19 | * [Countries](https://sypexgeo.net/files/SxGeoCountry.zip) 20 | * [Cities](https://sypexgeo.net/files/SxGeoCity_utf8.zip) 21 | 22 | > The database is updated 2 times a month. 23 | 24 | ## Setup 25 | 26 | ### Laravel 27 | 28 | 1\. If Laravel version <= 5.4, add into `config/app.php`: 29 | 30 | ```php 31 | 'providers' => [ 32 | \Eseath\SxGeo\SxGeoServiceProvider::class, 33 | ] 34 | ``` 35 | 36 | 2\. Publish config `sxgeo.php` (optionally): 37 | 38 | ``` 39 | php artisan vendor:publish --provider="Eseath\SxGeo\SxGeoServiceProvider" 40 | ``` 41 | 42 | By default, in config specified URL to the database of cities. If you want the database of countries, change url: 43 | 44 | ``` 45 | ... 46 | 'dbFileURL' => 'https://sypexgeo.net/files/SxGeoCountry.zip', 47 | ... 48 | ``` 49 | 50 | 3\. Download the database file: 51 | 52 | ``` 53 | php artisan sxgeo:update 54 | ``` 55 | 56 | You can use this command to upgrade database to the current version via CRON. 57 | 58 | ## Usage 59 | 60 | ```php 61 | use Eseath\SxGeo\SxGeo; 62 | 63 | $sxGeo = new SxGeo('/path/to/database/file.dat'); 64 | $fullInfo = $sxGeo->getCityFull($ip); 65 | $briefInfo = $sxGeo->get($ip); 66 | ``` 67 | 68 | ### With Laravel 69 | 70 | ```php 71 | use SxGeo; 72 | 73 | $data = SxGeo::getCityFull($ip); 74 | ``` 75 | 76 | ## Example Data 77 | 78 | ``` 79 | array:3 [▼ 80 | "city" => array:5 [▼ 81 | "id" => 524901 82 | "lat" => 55.75222 83 | "lon" => 37.61556 84 | "name_ru" => "Москва" 85 | "name_en" => "Moscow" 86 | ] 87 | "region" => array:4 [▼ 88 | "id" => 524894 89 | "name_ru" => "Москва" 90 | "name_en" => "Moskva" 91 | "iso" => "RU-MOW" 92 | ] 93 | "country" => array:6 [▼ 94 | "id" => 185 95 | "iso" => "RU" 96 | "lat" => 60 97 | "lon" => 100 98 | "name_ru" => "Россия" 99 | "name_en" => "Russia" 100 | ] 101 | ] 102 | ``` 103 | 104 | ``` 105 | array:2 [▼ 106 | "city" => array:5 [▼ 107 | "id" => 524901 108 | "lat" => 55.75222 109 | "lon" => 37.61556 110 | "name_ru" => "Москва" 111 | "name_en" => "Moscow" 112 | ] 113 | "country" => array:2 [▼ 114 | "id" => 185 115 | "iso" => "RU" 116 | ] 117 | ] 118 | ``` 119 | 120 | ## Running Tests 121 | 122 | The tests are run automatically by Travis CI on multiple PHP and Laravel versions. 123 | 124 | If you want to run tests locally, use the following command: 125 | 126 | ```shell 127 | python3 ./test.py 128 | ``` 129 | 130 | ## Development 131 | 132 | ```shell 133 | docker-compose run php-7.1 composer install 134 | ``` 135 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eseath/sypexgeo", 3 | "type": "library", 4 | "description": "A PHP package for working with the SypexGeo database file.", 5 | "keywords": ["sypexgeo", "sxgeo", "geoip", "geo", "laravel"], 6 | "license": "MIT", 7 | "support": { 8 | "issues": "https://github.com/Eseath/sypexgeo/issues", 9 | "source": "https://github.com/Eseath/sypexgeo" 10 | }, 11 | "authors": [ 12 | { 13 | "name": "Ruslan A.", 14 | "email": "ruslan.a94@yandex.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "^7.0|^8.0", 19 | "ext-curl": "*", 20 | "ext-zip": "*", 21 | "guzzlehttp/guzzle": "^6.0|^7.0" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^6.5|^7.5|^8.5|^9.5", 25 | "orchestra/testbench": "^3.6" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Eseath\\SxGeo\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Eseath\\SxGeo\\Tests\\": "tests/" 35 | } 36 | }, 37 | "extra": { 38 | "laravel": { 39 | "providers": [ 40 | "Eseath\\SxGeo\\SxGeoServiceProvider" 41 | ], 42 | "aliases": { 43 | "SxGeo": "Eseath\\SxGeo\\Facades\\SxGeo" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/sxgeo.php: -------------------------------------------------------------------------------- 1 | 'https://sypexgeo.net/files/SxGeoCity_utf8.zip', 7 | 8 | // The path where the database will be stored. 9 | 'localPath' => database_path('GeoIP.dat'), 10 | ]; 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | php-7.1: 5 | build: 6 | args: 7 | - PHP_VERSION=7.1 8 | volumes: 9 | - .:/opt/package 10 | environment: 11 | TESTBENCH: '5.0' 12 | entrypoint: [] 13 | 14 | php-7.1_laravel-5.1: 15 | build: 16 | args: 17 | - PHP_VERSION=7.1 18 | environment: 19 | - TESTBENCH_VERSION=3.1.* 20 | - PHPUNIT_VERSION=5.7 21 | - FAKER_VERSION=1.9 22 | 23 | php-7.1_laravel-5.2: 24 | build: 25 | args: 26 | - PHP_VERSION=7.1 27 | environment: 28 | - TESTBENCH_VERSION=3.2.* 29 | - PHPUNIT_VERSION=5.7 30 | 31 | php-7.1_laravel-5.3: 32 | build: 33 | args: 34 | - PHP_VERSION=7.1 35 | environment: 36 | - TESTBENCH_VERSION=3.3.* 37 | - PHPUNIT_VERSION=5.7 38 | 39 | php-7.1_laravel-5.4: 40 | build: 41 | args: 42 | - PHP_VERSION=7.1 43 | environment: 44 | - TESTBENCH_VERSION=3.4.* 45 | - PHPUNIT_VERSION=5.7 46 | 47 | php-7.1_laravel-5.5: 48 | build: 49 | args: 50 | - PHP_VERSION=7.1 51 | environment: 52 | - TESTBENCH_VERSION=3.5.* 53 | 54 | php-7.1_laravel-5.6: 55 | build: 56 | args: 57 | - PHP_VERSION=7.1 58 | environment: 59 | - TESTBENCH_VERSION=3.6.* 60 | 61 | php-7.1_laravel-5.7: 62 | build: 63 | args: 64 | - PHP_VERSION=7.1 65 | environment: 66 | - TESTBENCH_VERSION=3.7.* 67 | 68 | php-7.1_laravel-5.8: 69 | build: 70 | args: 71 | - PHP_VERSION=7.1 72 | environment: 73 | - TESTBENCH_VERSION=3.8.* 74 | 75 | php-7.2_laravel-6: 76 | build: 77 | args: 78 | - PHP_VERSION=7.2 79 | environment: 80 | - TESTBENCH_VERSION=4.0.* 81 | 82 | php-7.2_laravel-7: 83 | build: 84 | args: 85 | - PHP_VERSION=7.2 86 | environment: 87 | - TESTBENCH_VERSION=5.0.* 88 | 89 | php-7.3_laravel-7: 90 | build: 91 | args: 92 | - PHP_VERSION=7.3 93 | environment: 94 | - TESTBENCH_VERSION=5.0.* 95 | 96 | php-7.4_laravel-7: 97 | build: 98 | args: 99 | - PHP_VERSION=7.4 100 | environment: 101 | - TESTBENCH_VERSION=5.0.* 102 | 103 | php-8.0_laravel-8: 104 | build: 105 | args: 106 | - PHP_VERSION=8.0 107 | environment: 108 | - TESTBENCH_VERSION=6.0.* 109 | 110 | php-8.1_laravel-8: 111 | build: 112 | args: 113 | - PHP_VERSION=8.1 114 | environment: 115 | - TESTBENCH_VERSION=6.0.* 116 | 117 | php-8.2_laravel-9: 118 | build: 119 | args: 120 | - PHP_VERSION=8.2 121 | environment: 122 | - TESTBENCH_VERSION=7.* 123 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | rm -f composer.lock 2 | composer require orchestra/testbench:${TESTBENCH_VERSION} --no-update --no-interaction --dev 3 | 4 | if [ ! -z "$PHPUNIT_VERSION" ]; then 5 | composer require phpunit/phpunit:${PHPUNIT_VERSION} --no-update --no-interaction --dev 6 | fi 7 | 8 | if [ ! -z "$FAKER_VERSION" ]; then 9 | composer require fzaninotto/faker:${FAKER_VERSION} --no-update --no-interaction --dev 10 | fi 11 | 12 | composer install --no-interaction --no-progress --no-ansi 13 | echo "" 14 | composer show | grep laravel 15 | echo "" 16 | php --version 17 | echo "" 18 | curl --version 19 | echo "" 20 | vendor/bin/phpunit 21 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | 20 | ./src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Commands/SxGeoUpdate.php: -------------------------------------------------------------------------------- 1 | updater = new Updater(config('sxgeo.dbFileURL'), config('sxgeo.localPath')); 25 | } 26 | 27 | public function handle() 28 | { 29 | $this->output->getFormatter()->setStyle('title', new OutputFormatterStyle('blue', null, ['bold'])); 30 | $this->output->newLine(); 31 | 32 | if (! $this->option('force') && ! $this->checkForUpdates()) { 33 | return; 34 | } 35 | 36 | $this->download(); 37 | 38 | if (! $this->extract()) { 39 | return; 40 | } 41 | 42 | $this->info(PHP_EOL . 'The SypexGeo database successfully updated.'); 43 | $this->output->newLine(); 44 | } 45 | 46 | protected function checkForUpdates() 47 | { 48 | $this->output->writeln('• Checking for updates...'); 49 | 50 | if (! $this->updater->checkForUpdates()) { 51 | $this->info(' No updates available.'); 52 | $this->output->newLine(); 53 | return false; 54 | } 55 | 56 | $this->info(' Updates available!'); 57 | 58 | return true; 59 | } 60 | 61 | protected function download() 62 | { 63 | $this->output->writeln('• Downloading database file...'); 64 | 65 | $progressBar = $this->output->createProgressBar(); 66 | $progressBar->setFormatDefinition('custom', ' Downloaded %now%Mb/%total%Mb (%dlPercent%%)'); 67 | $progressBar->setFormat('custom'); 68 | $progressBar->start(); 69 | 70 | $this->updater->download(function () use ($progressBar) { 71 | $args = func_get_args(); 72 | 73 | // See https://php.watch/versions/8.0/resource-CurlHandle 74 | // See https://github.com/guzzle/guzzle/blob/6.5.5/src/Handler/CurlFactory.php#L485 75 | if (PHP_MAJOR_VERSION === 8 && is_object($args[0])) { 76 | array_shift($args); 77 | } 78 | 79 | list($totalBytes, $downloadedBytes) = $args; 80 | 81 | if ($totalBytes !== 0) { 82 | $totalMb = number_format($totalBytes / (1024 * 1024), 2); 83 | $downloadedMb = number_format($downloadedBytes / (1024 * 1024), 2); 84 | 85 | if ($downloadedBytes < $totalBytes) { 86 | $progressBar->setMessage($totalMb, 'total'); 87 | $progressBar->setMessage($downloadedMb, 'now'); 88 | $progressBar->setMessage((string) (int) ($downloadedMb / ($totalMb / 100)), 'dlPercent'); 89 | $progressBar->advance(); 90 | } else { 91 | $progressBar->finish(); 92 | } 93 | } 94 | }); 95 | 96 | $this->output->newLine(); 97 | } 98 | 99 | protected function extract() 100 | { 101 | $this->output->writeln('• Extracting file from archive...'); 102 | 103 | try { 104 | $this->updater->extract(); 105 | $this->info(' File copied to ' . $this->updater->getDestinationPath()); 106 | } catch (\Exception $e) { 107 | $this->output->writeln(' ' . $e->getMessage() . ''); 108 | $this->output->writeln(' ' . $e->getFile() . '#' . $e->getLine() . ''); 109 | $this->output->newLine(); 110 | return false; 111 | } 112 | 113 | return true; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/DatabaseType.php: -------------------------------------------------------------------------------- 1 | fh = fopen($db_file, 'rb'); 89 | 90 | // Сначала убеждаемся, что есть файл базы данных 91 | $header = fread($this->fh, 40); // В версии 2.2 заголовок увеличился на 8 байт 92 | 93 | if (strpos($header, 'SxG') !== 0) { 94 | die("Can't open {$db_file}\n"); 95 | } 96 | 97 | $info = unpack('Cver/Ntime/Ctype/Ccharset/Cb_idx_len/nm_idx_len/nrange/Ndb_items/Cid_len/nmax_region/nmax_city/Nregion_size/Ncity_size/nmax_country/Ncountry_size/npack_size', substr($header, 3)); 98 | 99 | if ($info['b_idx_len'] * $info['m_idx_len'] * $info['range'] * $info['db_items'] * $info['time'] * $info['id_len'] == 0) { 100 | die("Wrong file format {$db_file}\n"); 101 | } 102 | 103 | $this->version = (string) ($info['ver'] / 10); 104 | $this->range = $info['range']; 105 | $this->b_idx_len = $info['b_idx_len']; 106 | $this->m_idx_len = $info['m_idx_len']; 107 | $this->db_items = $info['db_items']; 108 | $this->id_len = $info['id_len']; 109 | $this->block_len = 3 + $this->id_len; 110 | $this->max_region = $info['max_region']; 111 | $this->max_city = $info['max_city']; 112 | $this->max_country = $info['max_country']; 113 | $this->country_size= $info['country_size']; 114 | $this->batch_mode = $type & SXGEO_BATCH; 115 | $this->memory_mode = $type & SXGEO_MEMORY; 116 | $this->pack = $info['pack_size'] ? explode("\0", fread($this->fh, $info['pack_size'])) : ''; 117 | $this->b_idx_str = fread($this->fh, $info['b_idx_len'] * 4); 118 | $this->m_idx_str = fread($this->fh, $info['m_idx_len'] * 4); 119 | 120 | $this->db_begin = ftell($this->fh); 121 | 122 | if ($this->batch_mode) { 123 | $this->b_idx_arr = array_values(unpack("N*", $this->b_idx_str)); // Быстрее в 5 раз, чем с циклом 124 | unset($this->b_idx_str); 125 | $this->m_idx_arr = str_split($this->m_idx_str, 4); // Быстрее в 5 раз чем с циклом 126 | unset($this->m_idx_str); 127 | } 128 | 129 | if ($this->memory_mode) { 130 | $this->db = fread($this->fh, $this->db_items * $this->block_len); 131 | $this->regions_db = $info['region_size'] > 0 ? fread($this->fh, $info['region_size']) : ''; 132 | $this->cities_db = $info['city_size'] > 0 ? fread($this->fh, $info['city_size']) : ''; 133 | } 134 | 135 | $this->info = $info; 136 | $this->info['regions_begin'] = $this->db_begin + $this->db_items * $this->block_len; 137 | $this->info['cities_begin'] = $this->info['regions_begin'] + $info['region_size']; 138 | } 139 | 140 | protected function search_idx($ipn, $min, $max) 141 | { 142 | if ($this->batch_mode) { 143 | while ($max - $min > 8) { 144 | $offset = ($min + $max) >> 1; 145 | if ($ipn > $this->m_idx_arr[$offset]) { 146 | $min = $offset; 147 | } else { 148 | $max = $offset; 149 | } 150 | } 151 | while ($ipn > $this->m_idx_arr[$min] && $min++ < $max) {} 152 | } else { 153 | while ($max - $min > 8) { 154 | $offset = ($min + $max) >> 1; 155 | if ($ipn > substr($this->m_idx_str, $offset*4, 4)) { 156 | $min = $offset; 157 | } else { 158 | $max = $offset; 159 | } 160 | } 161 | while ($ipn > substr($this->m_idx_str, $min * 4, 4) && $min++ < $max) {} 162 | } 163 | return $min; 164 | } 165 | 166 | protected function search_db($str, $ipn, $min, $max) 167 | { 168 | if ($max - $min > 1) { 169 | $ipn = substr($ipn, 1); 170 | while ($max - $min > 8){ 171 | $offset = ($min + $max) >> 1; 172 | if ($ipn > substr($str, $offset * $this->block_len, 3)) { 173 | $min = $offset; 174 | } else { 175 | $max = $offset; 176 | } 177 | } 178 | while ($ipn >= substr($str, $min * $this->block_len, 3) && ++$min < $max) {} 179 | } else { 180 | $min++; 181 | } 182 | 183 | return hexdec(bin2hex(substr($str, $min * $this->block_len - $this->id_len, $this->id_len))); 184 | } 185 | 186 | public function get_num($ip) 187 | { 188 | $ip1n = (int) $ip; // Первый байт 189 | 190 | if ($ip1n == 0 || $ip1n == 10 || $ip1n == 127 || $ip1n >= $this->b_idx_len || false === ($ipn = ip2long($ip))) { 191 | return false; 192 | } 193 | 194 | $ipn = pack('N', $ipn); 195 | $this->ip1c = chr($ip1n); 196 | 197 | // Находим блок данных в индексе первых байт 198 | if ($this->batch_mode){ 199 | $blocks = array('min' => $this->b_idx_arr[$ip1n-1], 'max' => $this->b_idx_arr[$ip1n]); 200 | } else { 201 | $blocks = unpack("Nmin/Nmax", substr($this->b_idx_str, ($ip1n - 1) * 4, 8)); 202 | } 203 | 204 | if ($blocks['max'] - $blocks['min'] > $this->range){ 205 | // Ищем блок в основном индексе 206 | $part = $this->search_idx($ipn, floor($blocks['min'] / $this->range), floor($blocks['max'] / $this->range)-1); 207 | 208 | // Нашли номер блока в котором нужно искать IP, теперь находим нужный блок в БД 209 | $min = $part > 0 ? $part * $this->range : 0; 210 | $max = $part > $this->m_idx_len ? $this->db_items : ($part+1) * $this->range; 211 | 212 | // Нужно проверить чтобы блок не выходил за пределы блока первого байта 213 | if ($min < $blocks['min']) { 214 | $min = $blocks['min']; 215 | } 216 | 217 | if ($max > $blocks['max']) { 218 | $max = $blocks['max']; 219 | } 220 | } else { 221 | $min = $blocks['min']; 222 | $max = $blocks['max']; 223 | } 224 | 225 | $len = $max - $min; 226 | 227 | // Находим нужный диапазон в БД 228 | if ($this->memory_mode) { 229 | return $this->search_db($this->db, $ipn, $min, $max); 230 | } 231 | 232 | fseek($this->fh, $this->db_begin + $min * $this->block_len); 233 | 234 | return $this->search_db(fread($this->fh, $len * $this->block_len), $ipn, 0, $len); 235 | } 236 | 237 | protected function readData($seek, $max, $type) 238 | { 239 | $raw = ''; 240 | 241 | if ($seek && $max) { 242 | if ($this->memory_mode) { 243 | $raw = substr($type == 1 ? $this->regions_db : $this->cities_db, $seek, $max); 244 | } else { 245 | fseek($this->fh, $this->info[$type == 1 ? 'regions_begin' : 'cities_begin'] + $seek); 246 | $raw = fread($this->fh, $max); 247 | } 248 | } 249 | 250 | return $this->unpack($this->pack[$type], $raw); 251 | } 252 | 253 | protected function parseCity($seek, $full = false) 254 | { 255 | if (!$this->pack) { 256 | return false; 257 | } 258 | 259 | $only_country = false; 260 | 261 | if ($seek < $this->country_size){ 262 | $country = $this->readData($seek, $this->max_country, 0); 263 | $city = $this->unpack($this->pack[2]); 264 | $city['lat'] = $country['lat']; 265 | $city['lon'] = $country['lon']; 266 | $only_country = true; 267 | } else { 268 | $city = $this->readData($seek, $this->max_city, 2); 269 | $country = ['id' => $city['country_id'], 'iso' => $this->id2iso[$city['country_id']]]; 270 | unset($city['country_id']); 271 | } 272 | 273 | if ($full) { 274 | $region = $this->readData($city['region_seek'], $this->max_region, 1); 275 | 276 | if (!$only_country) { 277 | $country = $this->readData($region['country_seek'], $this->max_country, 0); 278 | } 279 | 280 | unset($city['region_seek'], $region['country_seek']); 281 | 282 | return ['city' => $city, 'region' => $region, 'country' => $country]; 283 | } 284 | 285 | unset($city['region_seek']); 286 | 287 | return [ 288 | 'city' => $city, 289 | 'country' => [ 290 | 'id' => $country['id'], 291 | 'iso' => $country['iso'], 292 | ], 293 | ]; 294 | } 295 | 296 | protected function unpack($pack, $item = '') 297 | { 298 | $unpacked = []; 299 | $empty = empty($item); 300 | $pack = explode('/', $pack); 301 | $pos = 0; 302 | 303 | foreach ($pack AS $p) { 304 | list($type, $name) = explode(':', $p); 305 | $type0 = $type[0]; 306 | 307 | if ($empty) { 308 | $unpacked[$name] = $type0 == 'b' || $type0 == 'c' ? '' : 0; 309 | continue; 310 | } 311 | 312 | switch ($type0) { 313 | case 't': 314 | case 'T': $l = 1; break; 315 | case 's': 316 | case 'n': 317 | case 'S': $l = 2; break; 318 | case 'm': 319 | case 'M': $l = 3; break; 320 | case 'd': $l = 8; break; 321 | case 'c': $l = (int)substr($type, 1); break; 322 | case 'b': $l = strpos($item, "\0", $pos)-$pos; break; 323 | default: $l = 4; 324 | } 325 | 326 | $val = substr($item, $pos, $l); 327 | 328 | switch ($type0) { 329 | case 't': $v = unpack('c', $val); break; 330 | case 'T': $v = unpack('C', $val); break; 331 | case 's': $v = unpack('s', $val); break; 332 | case 'S': $v = unpack('S', $val); break; 333 | case 'm': $v = unpack('l', $val . ((ord($val[2]) >> 7) ? "\xff" : "\0")); break; 334 | case 'M': $v = unpack('L', $val . "\0"); break; 335 | case 'i': $v = unpack('l', $val); break; 336 | case 'I': $v = unpack('L', $val); break; 337 | case 'f': $v = unpack('f', $val); break; 338 | case 'd': $v = unpack('d', $val); break; 339 | 340 | case 'n': $v = current(unpack('s', $val)) / (10 ** $type[1]); break; 341 | case 'N': $v = current(unpack('l', $val)) / (10 ** $type[1]); break; 342 | 343 | case 'c': $v = rtrim($val, ' '); break; 344 | case 'b': $v = $val; $l++; break; 345 | } 346 | 347 | $pos += $l; 348 | $unpacked[$name] = is_array($v) ? current($v) : $v; 349 | } 350 | 351 | return $unpacked; 352 | } 353 | 354 | public function get($ip) 355 | { 356 | return $this->max_city ? $this->getCity($ip) : $this->getCountry($ip); 357 | } 358 | 359 | public function getCountry($ip) 360 | { 361 | if ($this->max_city) { 362 | $tmp = $this->parseCity($this->get_num($ip)); 363 | return $tmp['country']['iso']; 364 | } 365 | return $this->id2iso[$this->get_num($ip)]; 366 | } 367 | 368 | public function getCountryId($ip) 369 | { 370 | if ($this->max_city) { 371 | $tmp = $this->parseCity($this->get_num($ip)); 372 | return $tmp['country']['id']; 373 | } 374 | return $this->get_num($ip); 375 | } 376 | 377 | public function getCity($ip) 378 | { 379 | $seek = $this->get_num($ip); 380 | return $seek ? $this->parseCity($seek) : false; 381 | } 382 | 383 | public function getCityFull($ip) 384 | { 385 | $seek = $this->get_num($ip); 386 | return $seek ? $this->parseCity($seek, 1) : false; 387 | } 388 | 389 | /** @deprecated Will be deleted in next major release. Use getMetadata() instead. */ 390 | public function about() : array 391 | { 392 | return $this->getMetadata(); 393 | } 394 | 395 | public function getMetadata() : array 396 | { 397 | return [ 398 | 'Version' => $this->version, 399 | 'Created' => date('Y.m.d', $this->info['time']), 400 | 'Timestamp' => $this->info['time'], 401 | 'Charset' => self::CHARSETS[$this->info['charset']], 402 | 'Type' => self::TYPES[$this->info['type']], 403 | 'Byte Index' => $this->b_idx_len, 404 | 'Main Index' => $this->m_idx_len, 405 | 'Blocks In Index Item' => $this->range, 406 | 'IP Blocks' => $this->db_items, 407 | 'Block Size' => $this->block_len, 408 | 'City' => [ 409 | 'Max Length' => $this->max_city, 410 | 'Total Size' => $this->info['city_size'], 411 | ], 412 | 'Region' => [ 413 | 'Max Length' => $this->max_region, 414 | 'Total Size' => $this->info['region_size'], 415 | ], 416 | 'Country' => [ 417 | 'Max Length' => $this->max_country, 418 | 'Total Size' => $this->info['country_size'], 419 | ], 420 | ]; 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/SxGeoServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 25 | __DIR__ . '/../config/sxgeo.php' => config_path('sxgeo.php'), 26 | ]); 27 | 28 | if ($this->app->runningInConsole()) { 29 | $this->commands([ 30 | SxGeoUpdate::class, 31 | ]); 32 | } 33 | } 34 | 35 | /** 36 | * Register bindings in the container. 37 | * 38 | * @return void 39 | */ 40 | public function register() 41 | { 42 | $this->mergeConfigFrom(__DIR__ . '/../config/sxgeo.php', 'sxgeo'); 43 | 44 | $this->app->singleton(SxGeo::class, function ($app) { 45 | return new SxGeo($app['config']['sxgeo']['localPath']); 46 | }); 47 | } 48 | 49 | /** 50 | * Get the services provided by the provider. 51 | * 52 | * @return array 53 | */ 54 | public function provides() : array 55 | { 56 | return ['sxgeo']; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Updater.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 25 | $this->url = $url; 26 | $this->dstPath = $dstPath; 27 | $this->tmpDir = sys_get_temp_dir(); 28 | } 29 | 30 | public function getDestinationPath() : string 31 | { 32 | return $this->dstPath; 33 | } 34 | 35 | public function checkForUpdates() : bool 36 | { 37 | if (! file_exists($this->dstPath)) { 38 | return true; 39 | } 40 | 41 | $response = $this->client->head($this->url); 42 | 43 | $lastModified = new \DateTime($response->getHeaderLine('Last-Modified')); 44 | $fileModifiedTime = (new \DateTime())->setTimestamp(filemtime($this->dstPath)); 45 | 46 | return $lastModified > $fileModifiedTime; 47 | } 48 | 49 | public function download(callable $progress = null) 50 | { 51 | $this->tmpPath = implode(DIRECTORY_SEPARATOR, [$this->tmpDir, 'sypexgeo-' . md5(microtime()) . '.zip']); 52 | $resource = Utils::tryFopen($this->tmpPath, 'w'); 53 | 54 | $this->client->get($this->url, [ 55 | 'sink' => $resource, 56 | 'progress' => $progress, 57 | ]); 58 | } 59 | 60 | public function extract() 61 | { 62 | $extractPath = implode(DIRECTORY_SEPARATOR, [$this->tmpDir, 'sypexgeo-' . md5(microtime())]); 63 | 64 | $zip = new \ZipArchive(); 65 | $res = $zip->open($this->tmpPath); 66 | 67 | if ($res !== true) { 68 | throw new \Exception("Extraction failed: error code $res"); 69 | } 70 | 71 | $fileName = $zip->getNameIndex(0); 72 | 73 | $zip->extractTo($extractPath); 74 | $zip->close(); 75 | 76 | if (!copy($extractPath . DIRECTORY_SEPARATOR . $fileName, $this->dstPath)) { 77 | throw new \Exception('Copy failed.'); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import signal 4 | import subprocess 5 | import yaml 6 | import time 7 | import threading 8 | 9 | 10 | class Colors: 11 | SUCCESS = '\033[92m' 12 | FAILURE = '\033[91m' 13 | LOADING = '\033[96m' 14 | BOLD = '\033[1m' 15 | END = '\033[0m' 16 | 17 | 18 | class Spinner: 19 | text = '' 20 | 21 | def __init__(self, text=''): 22 | self.stop_running = threading.Event() 23 | self.spin_thread = threading.Thread(target=self.init_spin) 24 | self.text = text 25 | self.view = self.make_view() 26 | 27 | @staticmethod 28 | def make_view(): 29 | while True: 30 | for cursor in ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']: 31 | yield cursor 32 | 33 | def start(self): 34 | self.spin_thread.start() 35 | 36 | def stop(self): 37 | self.stop_running.set() 38 | self.spin_thread.join() 39 | 40 | def init_spin(self): 41 | while not self.stop_running.is_set(): 42 | sys.stdout.write(Colors.LOADING + next(self.view) + Colors.END + self.text) 43 | sys.stdout.flush() 44 | time.sleep(0.1) 45 | sys.stdout.write('\r') 46 | 47 | 48 | def hide_cursor(): 49 | sys.stdout.write("\033[?25l") 50 | sys.stdout.flush() 51 | 52 | 53 | def show_cursor(): 54 | sys.stdout.write("\033[?25h") 55 | sys.stdout.flush() 56 | 57 | 58 | def handle_signal(sig, frame): 59 | show_cursor() 60 | sys.exit(0) 61 | 62 | 63 | def run_task(cmd, description): 64 | spinner = Spinner(text=f' {description}') 65 | spinner.start() 66 | result = subprocess.run(cmd, capture_output=True, text=True) 67 | spinner.stop() 68 | if result.returncode == 0: 69 | sys.stdout.write(Colors.SUCCESS + '✓' + Colors.END + f' {description}\n') 70 | else: 71 | sys.stdout.write(Colors.FAILURE + '⨯' + Colors.END + f' {description}\n') 72 | sys.stdout.flush() 73 | 74 | 75 | def run_docker_service(service_name): 76 | run_task( 77 | cmd=['docker', 'compose', 'run', '--rm', service_name], 78 | description=f'Testing in the {Colors.BOLD}{service_name}{Colors.END} container' 79 | ) 80 | 81 | 82 | signal.signal(signal.SIGINT, handle_signal) 83 | 84 | with open('docker-compose.yml', 'r') as stream: 85 | hide_cursor() 86 | run_task(['docker', 'compose', 'build'], 'Building docker containers') 87 | try: 88 | services = yaml.safe_load(stream)['services'] 89 | services = filter(lambda service_name: 'laravel' in service_name, services) 90 | for service in services: 91 | run_docker_service(service) 92 | except yaml.YAMLError as e: 93 | print(e) 94 | show_cursor() 95 | -------------------------------------------------------------------------------- /tests/.assets/SxGeoCity.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eseath/sypexgeo/b883326c5f952e836874b77cd357a75e15365e76/tests/.assets/SxGeoCity.dat -------------------------------------------------------------------------------- /tests/.assets/SxGeoCountry.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eseath/sypexgeo/b883326c5f952e836874b77cd357a75e15365e76/tests/.assets/SxGeoCountry.dat -------------------------------------------------------------------------------- /tests/CityDatabaseTest.php: -------------------------------------------------------------------------------- 1 | set('sxgeo.localPath', __DIR__ . '/.assets/SxGeoCity.dat'); 19 | 20 | $this->db = $this->app->make(SxGeo::class); 21 | } 22 | 23 | public function dataset() : array 24 | { 25 | return [ 26 | ['109.72.73.80', 'RU', 'Russia', 'Россия', 'RU-MOW', 'Moskva', 'Москва', 'Moscow', 'Москва'], 27 | ['67.207.76.98', 'DE', 'Germany', 'Германия', 'DE-HE', 'Land Hessen', 'Гессен', 'Frankfurt am Main', 'Франкфурт-на-Майне'], 28 | ['64.233.165.100', 'US', 'United States', 'США', 'US-AR', 'Arkansas', 'Арканзас', 'Mountain View', 'Маунтин-Вью'], 29 | ]; 30 | } 31 | 32 | /** @dataProvider dataset */ 33 | public function testResultOfCityDatabaseQuerying( 34 | string $ip, 35 | string $country_code, 36 | string $country_name_en, 37 | string $country_name_ru, 38 | string $region_code, 39 | string $region_name_en, 40 | string $region_name_ru, 41 | string $city_name_en, 42 | string $city_name_ru 43 | ) : void { 44 | $data = $this->db->getCityFull($ip); 45 | 46 | $this->assertSame($country_code, $data['country']['iso']); 47 | $this->assertSame($country_name_en, $data['country']['name_en']); 48 | $this->assertSame($country_name_ru, $data['country']['name_ru']); 49 | $this->assertSame($region_code, $data['region']['iso']); 50 | $this->assertSame($region_name_en, $data['region']['name_en']); 51 | $this->assertSame($region_name_ru, $data['region']['name_ru']); 52 | $this->assertSame($city_name_en, $data['city']['name_en']); 53 | $this->assertSame($city_name_ru, $data['city']['name_ru']); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/CountryDatabaseTest.php: -------------------------------------------------------------------------------- 1 | set('sxgeo.localPath', __DIR__ . '/.assets/SxGeoCountry.dat'); 19 | 20 | $this->db = $this->app->make(SxGeo::class); 21 | } 22 | 23 | public function dataset() : array 24 | { 25 | return [ 26 | ['109.72.73.80', 'RU'], 27 | ['67.207.76.98', 'DE'], 28 | ['64.233.165.100', 'US'], 29 | ]; 30 | } 31 | 32 | /** @dataProvider dataset */ 33 | public function testResultOfCityDatabaseQuerying(string $ip, string $country_code) : void 34 | { 35 | $this->assertSame($country_code, $this->db->getCountry($ip)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/FacadeTest.php: -------------------------------------------------------------------------------- 1 | set('sxgeo.localPath', __DIR__ . '/.assets/SxGeoCountry.dat'); 14 | 15 | $metadata = SxGeo::getMetadata(); 16 | 17 | $this->assertSame('2.2', $metadata['Version']); 18 | $this->assertSame('latin1', $metadata['Charset']); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('sxgeo.dbFileURL', 'https://sypexgeo.net/files/SxGeoCity_utf8.zip'); 27 | 28 | Artisan::call('sxgeo:update'); 29 | 30 | $db = new SxGeo(config('sxgeo.localPath')); 31 | $metadata = $db->getMetadata(); 32 | 33 | $this->assertSame(DatabaseType::CITY_EN, $metadata['Type']); 34 | $this->assertSame('2.2', $metadata['Version']); 35 | $this->assertSame('utf-8', $metadata['Charset']); 36 | } 37 | 38 | public function testCountryDatabase() : void 39 | { 40 | config()->set('sxgeo.dbFileURL', 'https://sypexgeo.net/files/SxGeoCountry.zip'); 41 | 42 | Artisan::call('sxgeo:update'); 43 | 44 | $db = new SxGeo(config('sxgeo.localPath')); 45 | $metadata = $db->getMetadata(); 46 | 47 | $this->assertSame(DatabaseType::COUNTRY, $metadata['Type']); 48 | $this->assertSame('2.2', $metadata['Version']); 49 | } 50 | 51 | public function testDbMustBeUpdatedWithForceArgument() : void 52 | { 53 | config()->set('sxgeo.dbFileURL', 'https://sypexgeo.net/files/SxGeoCountry.zip'); 54 | $path = config('sxgeo.localPath'); 55 | 56 | Artisan::call('sxgeo:update'); 57 | $ctime1 = filectime($path); 58 | 59 | sleep(1); 60 | 61 | Artisan::call('sxgeo:update', ['--force' => true]); 62 | $ctime2 = filectime($path); 63 | 64 | $this->assertTrue($ctime2 > $ctime1); 65 | } 66 | 67 | public function testDbMustNotBeUpdatedWithoutForceArgument() : void 68 | { 69 | config()->set('sxgeo.dbFileURL', 'https://sypexgeo.net/files/SxGeoCountry.zip'); 70 | $path = config('sxgeo.localPath'); 71 | 72 | Artisan::call('sxgeo:update'); 73 | $ctime1 = filectime($path); 74 | 75 | sleep(1); 76 | 77 | Artisan::call('sxgeo:update'); 78 | $ctime2 = filectime($path); 79 | 80 | $this->assertSame($ctime1, $ctime2); 81 | } 82 | } 83 | --------------------------------------------------------------------------------