Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.16% covered (warning)
87.16%
95 / 109
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
IpGeolocationService
87.04% covered (warning)
87.04%
94 / 108
58.33% covered (warning)
58.33%
7 / 12
47.03
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
 resolveIp
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveAndCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resolveBulk
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isPrivateOrLoopback
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getLocalhostLocation
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 resolveFromReaderOrApi
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 resolveFromHttpFallback
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
8.51
 requestHttpJsonData
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 fetchFromCache
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
 queryCacheRow
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 saveToCache
72.73% covered (warning)
72.73%
16 / 22
0.00% covered (danger)
0.00%
0 / 1
4.32
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Map\Domain\Model\IpLocationDto;
12use App\Modules\Map\Domain\Repository\IpGeolocationServiceInterface;
13use App\Modules\Map\Domain\Repository\MaxMindMmdbReaderInterface;
14use PDO;
15use Throwable;
16
17/**
18 * Service resolving IP geolocation with SQL persistent cache, MaxMind MMDB, and HTTP fallback.
19 *
20 * @package App\Modules\Map\Application\Service
21 */
22final class IpGeolocationService implements IpGeolocationServiceInterface
23{
24    private const string DEFAULT_FALLBACK_URL =
25        'https://ip-api.com/json/%s?fields=status,country,countryCode,city,lat,lon,timezone';
26    private const string CACHE_COLUMNS =
27        '(`ip_address`, `country_code`, `country_name`, `city_name`, `latitude`, `longitude`, `timezone`, `hits`)';
28
29    /**
30     * @var callable|null
31     */
32    private $httpRequester;
33
34    public function __construct(
35        private readonly ?PDO $pdo = null,
36        private readonly ?MaxMindMmdbReaderInterface $mmdbReader = null,
37        ?callable $httpRequester = null,
38        private readonly string $fallbackApiUrl = self::DEFAULT_FALLBACK_URL
39    ) {
40        $this->httpRequester = $httpRequester;
41    }
42
43    /**
44     * {@inheritdoc}
45     */
46    public function resolveIp(string $ipAddress): ?IpLocationDto
47    {
48        $cleanIp = trim($ipAddress);
49        if ($cleanIp === '') {
50            return null;
51        }
52
53        if ($this->isPrivateOrLoopback($cleanIp)) {
54            return $this->getLocalhostLocation($cleanIp);
55        }
56
57        return $this->fetchFromCache($cleanIp) ?? $this->resolveAndCache($cleanIp);
58    }
59
60    private function resolveAndCache(string $cleanIp): ?IpLocationDto
61    {
62        $resolved = $this->resolveFromReaderOrApi($cleanIp);
63        if ($resolved !== null) {
64            $this->saveToCache($resolved);
65        }
66
67        return $resolved;
68    }
69
70    /**
71     * {@inheritdoc}
72     */
73    public function resolveBulk(array $ipAddresses): array
74    {
75        $resolved = [];
76        $uniqueIps = array_unique(array_filter(array_map('trim', $ipAddresses)));
77
78        foreach ($uniqueIps as $ip) {
79            $loc = $this->resolveIp($ip);
80            if ($loc !== null) {
81                $resolved[$ip] = $loc;
82            }
83        }
84
85        return $resolved;
86    }
87
88    /**
89     * Checks if IP is loopback, private or local.
90     */
91    private function isPrivateOrLoopback(string $ip): bool
92    {
93        return !filter_var(
94            $ip,
95            FILTER_VALIDATE_IP,
96            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
97        );
98    }
99
100    /**
101     * Returns standard mock location for localhost/LAN testing.
102     */
103    private function getLocalhostLocation(string $ip): IpLocationDto
104    {
105        return new IpLocationDto(
106            ipAddress:   $ip,
107            latitude:    52.2297,
108            longitude:   21.0122,
109            countryName: 'Poland (Localhost)',
110            countryCode: 'PL',
111            cityName:    'Warszawa (Lokalny)',
112            timezone:    'Europe/Warsaw'
113        );
114    }
115
116    /**
117     * Attempts to resolve IP from local MaxMind MMDB reader or external HTTP API.
118     */
119    private function resolveFromReaderOrApi(string $ip): ?IpLocationDto
120    {
121        if ($this->mmdbReader !== null && $this->mmdbReader->isAvailable()) {
122            try {
123                $loc = $this->mmdbReader->lookup($ip);
124                if ($loc !== null) {
125                    return $loc;
126                }
127            } catch (Throwable) {
128                // Fallback to HTTP
129            }
130        }
131
132        return $this->resolveFromHttpFallback($ip);
133    }
134
135    /**
136     * Fallback to free HTTP Geolocation API.
137     */
138    private function resolveFromHttpFallback(string $ip): ?IpLocationDto
139    {
140        $url = sprintf($this->fallbackApiUrl, urlencode($ip));
141
142        try {
143            $data = $this->requestHttpJsonData($url);
144            if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
145                return null;
146            }
147
148            return new IpLocationDto(
149                ipAddress:   $ip,
150                latitude:    (float) ($data['lat'] ?? 0.0),
151                longitude:   (float) ($data['lon'] ?? 0.0),
152                countryName: (string) ($data['country'] ?? '') ?: null,
153                countryCode: (string) ($data['countryCode'] ?? '') ?: null,
154                cityName:    (string) ($data['city'] ?? '') ?: null,
155                timezone:    (string) ($data['timezone'] ?? '') ?: null
156            );
157        } catch (Throwable) {
158            return null;
159        }
160    }
161
162    private function requestHttpJsonData(string $url): ?array
163    {
164        $responseBody = $this->httpRequester !== null
165            ? ($this->httpRequester)($url)
166            : @file_get_contents($url);
167
168        if (!is_string($responseBody) || trim($responseBody) === '') {
169            return null;
170        }
171
172        $decoded = json_decode($responseBody, true);
173        return is_array($decoded) ? $decoded : null;
174    }
175
176    /**
177     * Looks up IP location in the persistent cache table.
178     */
179    private function fetchFromCache(string $ip): ?IpLocationDto
180    {
181        if ($this->pdo === null) {
182            return null;
183        }
184
185        $dto = null;
186        try {
187            $row = $this->queryCacheRow($ip);
188            if ($row !== null) {
189                $dto = new IpLocationDto(
190                    ipAddress:   $ip,
191                    latitude:    (float) $row['latitude'],
192                    longitude:   (float) $row['longitude'],
193                    countryName: !empty($row['country_name']) ? (string) $row['country_name'] : null,
194                    countryCode: !empty($row['country_code']) ? (string) $row['country_code'] : null,
195                    cityName:    !empty($row['city_name']) ? (string) $row['city_name'] : null,
196                    timezone:    !empty($row['timezone']) ? (string) $row['timezone'] : null
197                );
198            }
199        } catch (Throwable) {
200            // Cache lookup failure yields null safely
201        }
202
203        return $dto;
204    }
205
206    private function queryCacheRow(string $ip): ?array
207    {
208        $sql = 'SELECT country_code, country_name, city_name, latitude, longitude, timezone ' .
209               'FROM `a_core_ip_geo_cache` WHERE `ip_address` = :ip LIMIT 1';
210        $stmt = $this->pdo?->prepare($sql);
211        $stmt?->execute([':ip' => $ip]);
212        $row = $stmt?->fetch(PDO::FETCH_ASSOC);
213
214        if (!is_array($row)) {
215            return null;
216        }
217
218        $updateSql = 'UPDATE `a_core_ip_geo_cache` SET `hits` = `hits` + 1 WHERE `ip_address` = :ip';
219        $this->pdo?->prepare($updateSql)->execute([':ip' => $ip]);
220
221        return $row;
222    }
223
224    /**
225     * Persists resolved IP geolocation to SQL database cache.
226     */
227    private function saveToCache(IpLocationDto $dto): void
228    {
229        if ($this->pdo === null) {
230            return;
231        }
232
233        try {
234            $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
235            if ($driver === 'sqlite') {
236                $sql = 'INSERT OR REPLACE INTO `a_core_ip_geo_cache` ' .
237                       self::CACHE_COLUMNS . ' ' .
238                       'VALUES (:ip, :cc, :cname, :city, :lat, :lon, :tz, 1)';
239            } else {
240                $sql = 'INSERT INTO `a_core_ip_geo_cache` ' .
241                       self::CACHE_COLUMNS . ' ' .
242                       'VALUES (:ip, :cc, :cname, :city, :lat, :lon, :tz, 1) ' .
243                       'ON DUPLICATE KEY UPDATE `hits` = `hits` + 1, `updated_at` = CURRENT_TIMESTAMP(6)';
244            }
245
246            $stmt = $this->pdo->prepare($sql);
247            $stmt->execute([
248                ':ip'    => $dto->ipAddress,
249                ':cc'    => $dto->countryCode,
250                ':cname' => $dto->countryName,
251                ':city'  => $dto->cityName,
252                ':lat'   => $dto->latitude,
253                ':lon'   => $dto->longitude,
254                ':tz'    => $dto->timezone,
255            ]);
256        } catch (Throwable) {
257            // Non-critical cache failure
258        }
259    }
260}