Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.64% covered (warning)
85.64%
167 / 195
69.57% covered (warning)
69.57%
16 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
MaxMindMmdbReader
85.57% covered (warning)
85.57%
166 / 194
69.57% covered (warning)
69.57%
16 / 23
103.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lookup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resolveRawRecord
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 buildIpLocationDto
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 isAvailable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 parseDatabaseMetadata
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 initializeFromMetaOffset
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 findRecord
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 readNode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 readNode24
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 readNode32
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 readNode28
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 decodeData
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 resolveSize
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
6.00
 decodePointer
33.33% covered (danger)
33.33%
7 / 21
0.00% covered (danger)
0.00%
0 / 1
8.74
 decodeValueByType
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
11
 decodeDouble
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 decodeFloat
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 decodeUint
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 decodeInt32
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 decodeMap
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 decodeArray
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Map\Infrastructure\GeoIp;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Map\Domain\Model\IpLocationDto;
12use App\Modules\Map\Domain\Repository\MaxMindMmdbReaderInterface;
13use RuntimeException;
14use Throwable;
15
16/**
17 * Pure PHP reader for MaxMind DB 2.0 binary database format (GeoLite2-City.mmdb).
18 *
19 * Implements binary search tree traversal and data decoding without external C extensions.
20 *
21 * @package App\Modules\Map\Infrastructure\GeoIp
22 */
23final class MaxMindMmdbReader implements MaxMindMmdbReaderInterface
24{
25    private const string METADATA_MARKER = "\xab\xcd\xefMaxMind.com";
26
27    private ?string $fileContent = null;
28    private int $fileSize = 0;
29    private int $nodeCount = 0;
30    private int $recordSize = 0;
31    private int $nodeOffsetMult = 0;
32    private int $treeSize = 0;
33    private int $ipVersion = 4;
34    private bool $isLoaded = false;
35
36    /**
37     * @param string $databasePath Absolute file path to .mmdb file.
38     */
39    public function __construct(private readonly string $databasePath)
40    {
41    }
42
43    /**
44     * Attempts to resolve IP to coordinates and metadata.
45     *
46     * @param string $ip IPv4 or IPv6 string.
47     * @return IpLocationDto|null Resolved DTO or null if not found.
48     */
49    public function lookup(string $ip): ?IpLocationDto
50    {
51        $rawRecord = $this->resolveRawRecord($ip);
52        if ($rawRecord === null) {
53            return null;
54        }
55
56        return $this->buildIpLocationDto($ip, $rawRecord);
57    }
58
59    /**
60     * @return array<string, mixed>|null
61     */
62    private function resolveRawRecord(string $ip): ?array
63    {
64        if (!$this->load()) {
65            return null;
66        }
67
68        $packedIp = @inet_pton(trim($ip));
69        if ($packedIp === false) {
70            return null;
71        }
72
73        $rawRecord = $this->findRecord($packedIp);
74        return is_array($rawRecord) ? $rawRecord : null;
75    }
76
77    /**
78     * @param array<string, mixed> $rawRecord
79     */
80    private function buildIpLocationDto(string $ip, array $rawRecord): ?IpLocationDto
81    {
82        $lat = isset($rawRecord['location']['latitude']) ? (float) $rawRecord['location']['latitude'] : null;
83        $lon = isset($rawRecord['location']['longitude']) ? (float) $rawRecord['location']['longitude'] : null;
84        if ($lat === null || $lon === null) {
85            return null;
86        }
87
88        $countryCode = (string) ($rawRecord['country']['iso_code'] ?? '');
89        $countryName = (string) ($rawRecord['country']['names']['en'] ?? $countryCode);
90        $cityName = (string) ($rawRecord['city']['names']['en'] ?? '');
91        $timeZone = (string) ($rawRecord['location']['time_zone'] ?? '');
92
93        return new IpLocationDto(
94            ipAddress:   trim($ip),
95            latitude:    $lat,
96            longitude:   $lon,
97            countryCode: $countryCode !== '' ? $countryCode : null,
98            countryName: $countryName !== '' ? $countryName : null,
99            cityName:    $cityName !== '' ? $cityName : null,
100            timezone:    $timeZone !== '' ? $timeZone : null
101        );
102    }
103
104    /**
105     * Returns true if database file is loaded and operational.
106     */
107    public function isAvailable(): bool
108    {
109        return $this->load();
110    }
111
112    /**
113     * Lazy-loads and parses MMDB metadata section.
114     */
115    private function load(): bool
116    {
117        if ($this->isLoaded) {
118            return true;
119        }
120
121        if (!file_exists($this->databasePath) || !is_readable($this->databasePath)) {
122            return false;
123        }
124
125        try {
126            $loaded = $this->parseDatabaseMetadata();
127        } catch (Throwable) {
128            $loaded = false;
129        }
130
131        return $loaded;
132    }
133
134    private function parseDatabaseMetadata(): bool
135    {
136        $content = file_get_contents($this->databasePath);
137        if ($content === false || strlen($content) < 128) {
138            return false;
139        }
140
141        $this->fileContent = $content;
142        $this->fileSize = strlen($content);
143
144        $markerPos = strrpos($this->fileContent, self::METADATA_MARKER);
145        if ($markerPos === false) {
146            return false;
147        }
148
149        $metaOffset = $markerPos + strlen(self::METADATA_MARKER);
150        return $this->initializeFromMetaOffset($metaOffset);
151    }
152
153    private function initializeFromMetaOffset(int $metaOffset): bool
154    {
155        $metaData = $this->decodeData($metaOffset);
156        if (!is_array($metaData[0])) {
157            return false;
158        }
159
160        $meta = $metaData[0];
161        $this->nodeCount = (int) ($meta['node_count'] ?? 0);
162        $this->recordSize = (int) ($meta['record_size'] ?? 0);
163        $this->ipVersion = (int) ($meta['ip_version'] ?? 4);
164
165        if ($this->nodeCount <= 0 || !in_array($this->recordSize, [24, 28, 32], true)) {
166            return false;
167        }
168
169        $this->nodeOffsetMult = ($this->recordSize * 2) / 8;
170        $this->treeSize = (int) ($this->nodeCount * $this->nodeOffsetMult);
171        $this->isLoaded = true;
172
173        return true;
174    }
175
176    /**
177     * Traverses the binary search tree for the given packed IP address.
178     */
179    private function findRecord(string $packedIp): mixed
180    {
181        $ipLen = strlen($packedIp);
182        $bitCount = $ipLen * 8;
183        $node = 0;
184
185        // If IPv4 in IPv6 database, traverse down 96 zeros to find IPv4 subtree
186        if ($this->ipVersion === 6 && $ipLen === 4) {
187            $packedIp = str_repeat("\x00", 12) . $packedIp;
188            $bitCount = 128;
189        }
190
191        for ($i = 0; $i < $bitCount && $node < $this->nodeCount; ++$i) {
192            $byte = ord($packedIp[(int) ($i / 8)]);
193            $bit = ($byte >> (7 - ($i % 8))) & 1;
194            $node = $this->readNode($node, $bit);
195        }
196
197        if ($node <= $this->nodeCount) {
198            return null;
199        }
200
201        // Pointer into data section
202        $dataOffset = $node - $this->nodeCount - 16;
203        $fileOffset = $this->treeSize + 16 + $dataOffset;
204        if ($fileOffset >= $this->fileSize) {
205            return null;
206        }
207
208        $decoded = $this->decodeData($fileOffset);
209        return $decoded[0] ?? null;
210    }
211
212    /**
213     * Reads the child node value (left or right) from tree record.
214     */
215    private function readNode(int $nodeIndex, int $index): int
216    {
217        $offset = $nodeIndex * $this->nodeOffsetMult;
218
219        return match ($this->recordSize) {
220            24 => $this->readNode24($offset, $index),
221            32 => $this->readNode32($offset, $index),
222            default => $this->readNode28($offset, $index),
223        };
224    }
225
226    private function readNode24(int $offset, int $index): int
227    {
228        if ($index === 0) {
229            return (ord($this->fileContent[$offset]) << 16)
230                | (ord($this->fileContent[$offset + 1]) << 8)
231                | ord($this->fileContent[$offset + 2]);
232        }
233        return (ord($this->fileContent[$offset + 3]) << 16)
234            | (ord($this->fileContent[$offset + 4]) << 8)
235            | ord($this->fileContent[$offset + 5]);
236    }
237
238    private function readNode32(int $offset, int $index): int
239    {
240        $pos = $offset + ($index * 4);
241        return (ord($this->fileContent[$pos]) << 24)
242            | (ord($this->fileContent[$pos + 1]) << 16)
243            | (ord($this->fileContent[$pos + 2]) << 8)
244            | ord($this->fileContent[$pos + 3]);
245    }
246
247    private function readNode28(int $offset, int $index): int
248    {
249        $middle = ord($this->fileContent[$offset + 3]);
250        if ($index === 0) {
251            return (($middle & 0xF0) << 20)
252                | (ord($this->fileContent[$offset]) << 16)
253                | (ord($this->fileContent[$offset + 1]) << 8)
254                | ord($this->fileContent[$offset + 2]);
255        }
256
257        return (($middle & 0x0F) << 24)
258            | (ord($this->fileContent[$offset + 4]) << 16)
259            | (ord($this->fileContent[$offset + 5]) << 8)
260            | ord($this->fileContent[$offset + 6]);
261    }
262
263    /**
264     * Decodes data item from data section at specified offset.
265     *
266     * @return array{0: mixed, 1: int} Array of [decodedValue, nextOffset].
267     */
268    private function decodeData(int $offset): array
269    {
270        if ($offset >= $this->fileSize) {
271            return [null, $offset];
272        }
273
274        $ctrl = ord($this->fileContent[$offset]);
275        $type = $ctrl >> 5;
276        $size = $ctrl & 0x1F;
277        $current = $offset + 1;
278
279        // Extended type 0 indicates next byte is actual type - 7
280        if ($type === 0) {
281            $type = ord($this->fileContent[$current]) + 7;
282            $current++;
283        }
284
285        if ($type === 1) { // Pointer
286            $pSize = ($ctrl >> 3) & 0x03;
287            return $this->decodePointer($ctrl, $pSize, $current);
288        }
289
290        [$size, $current] = $this->resolveSize($size, $current);
291
292        return $this->decodeValueByType($type, $size, $current);
293    }
294
295    /**
296     * Resolves size field for standard MMDB types.
297     */
298    private function resolveSize(int $size, int $current): array
299    {
300        if ($size === 29) {
301            $size = 29 + ord($this->fileContent[$current]);
302            $current++;
303        } elseif ($size === 30) {
304            $size = 285 + ((ord($this->fileContent[$current]) << 8) | ord($this->fileContent[$current + 1]));
305            $current += 2;
306        } elseif ($size === 31) {
307            $size = 65821 + ((ord($this->fileContent[$current]) << 16)
308                | (ord($this->fileContent[$current + 1]) << 8)
309                | ord($this->fileContent[$current + 2]));
310            $current += 3;
311        }
312        return [$size, $current];
313    }
314
315    /**
316     * Decodes a pointer reference to another location in the data section.
317     */
318    private function decodePointer(int $ctrl, int $pSize, int $current): array
319    {
320        $b1 = ord($this->fileContent[$current]);
321        if ($pSize === 0) {
322            $ptr = (($ctrl & 0x07) << 8) | $b1;
323            $current += 1;
324        } elseif ($pSize === 1) {
325            $b2 = ord($this->fileContent[$current + 1]);
326            $ptr = 2048 + ((($ctrl & 0x07) << 16) | ($b1 << 8) | $b2);
327            $current += 2;
328        } elseif ($pSize === 2) {
329            $b2 = ord($this->fileContent[$current + 1]);
330            $b3 = ord($this->fileContent[$current + 2]);
331            $ptr = 526336 + ((($ctrl & 0x07) << 24) | ($b1 << 16) | ($b2 << 8) | $b3);
332            $current += 3;
333        } else {
334            $b2 = ord($this->fileContent[$current + 1]);
335            $b3 = ord($this->fileContent[$current + 2]);
336            $b4 = ord($this->fileContent[$current + 3]);
337            $ptr = ($b1 << 24) | ($b2 << 16) | ($b3 << 8) | $b4;
338            $current += 4;
339        }
340
341        $targetOffset = $this->treeSize + 16 + $ptr;
342        $decoded = $this->decodeData($targetOffset);
343
344        return [$decoded[0], $current];
345    }
346
347    /**
348     * Decodes value by resolved MaxMind data type.
349     */
350    private function decodeValueByType(int $type, int $size, int $current): array
351    {
352        return match ($type) {
353            2 => [substr($this->fileContent, $current, $size), $current + $size], // UTF-8 String
354            3 => $this->decodeDouble($current), // Double (IEEE 754)
355            4 => [substr($this->fileContent, $current, $size), $current + $size], // Bytes
356            5, 6, 9, 10 => $this->decodeUint($size, $current), // Unsigned integers
357            7 => $this->decodeMap($size, $current), // Map
358            8 => $this->decodeInt32($size, $current), // Signed 32-bit int
359            11 => $this->decodeArray($size, $current), // Array
360            14 => [$size === 1, $current], // Boolean
361            15 => $this->decodeFloat($current), // Float
362            default => [null, $current + $size],
363        };
364    }
365
366    private function decodeDouble(int $current): array
367    {
368        $raw = substr($this->fileContent, $current, 8);
369        $val = unpack('E', $raw)[1] ?? 0.0;
370        return [(float) $val, $current + 8];
371    }
372
373    private function decodeFloat(int $current): array
374    {
375        $raw = substr($this->fileContent, $current, 4);
376        $val = unpack('G', $raw)[1] ?? 0.0;
377        return [(float) $val, $current + 4];
378    }
379
380    private function decodeUint(int $size, int $current): array
381    {
382        $val = 0;
383        for ($i = 0; $i < $size; ++$i) {
384            $val = ($val << 8) | ord($this->fileContent[$current + $i]);
385        }
386        return [$val, $current + $size];
387    }
388
389    private function decodeInt32(int $size, int $current): array
390    {
391        $val = 0;
392        for ($i = 0; $i < $size; ++$i) {
393            $val = ($val << 8) | ord($this->fileContent[$current + $i]);
394        }
395        if ($size === 4 && ($val & 0x80000000)) {
396            $val -= 0x100000000;
397        }
398        return [$val, $current + $size];
399    }
400
401    private function decodeMap(int $size, int $current): array
402    {
403        $map = [];
404        for ($i = 0; $i < $size; ++$i) {
405            [$key, $current] = $this->decodeData($current);
406            [$val, $current] = $this->decodeData($current);
407            if (is_string($key) || is_int($key)) {
408                $map[$key] = $val;
409            }
410        }
411        return [$map, $current];
412    }
413
414    private function decodeArray(int $size, int $current): array
415    {
416        $arr = [];
417        for ($i = 0; $i < $size; ++$i) {
418            [$val, $current] = $this->decodeData($current);
419            $arr[] = $val;
420        }
421        return [$arr, $current];
422    }
423}