├── .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 | [](https://php.net/)
4 | [](https://packagist.org/packages/eseath/sypexgeo)
5 | [](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 |
--------------------------------------------------------------------------------