Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.31% covered (warning)
67.31%
35 / 52
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
GeoIpLookupService
67.31% covered (warning)
67.31%
35 / 52
40.00% covered (danger)
40.00%
2 / 5
29.32
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
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
8.61
 getReader
60.00% covered (warning)
60.00%
12 / 20
0.00% covered (danger)
0.00%
0 / 1
8.30
 __destruct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 closeReader
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\GeoIp;
10
11use OCA\Libresign\Enum\SignerIpGeolocationUnavailableReason;
12use OCA\Libresign\Vendor\GeoIp2\Database\Reader;
13use OCA\Libresign\Vendor\GeoIp2\Exception\AddressNotFoundException;
14use Psr\Log\LoggerInterface;
15
16/**
17 * Looks up approximate location from a local MaxMind City database.
18 *
19 * MaxMind-specific access stays behind this service. An opened reader is reused
20 * for the lifetime of this request-scoped service instance.
21 */
22class GeoIpLookupService {
23    private ?Reader $reader = null;
24    private ?string $openedPath = null;
25
26    public function __construct(
27        private GeoIpConfigService $geoIpConfigService,
28        private GeoIpDatabaseStatusService $geoIpDatabaseStatusService,
29        private GeoIpLookupResultNormalizer $normalizer,
30        private LoggerInterface $logger,
31    ) {
32    }
33
34    /**
35     * @return array<string, mixed>
36     */
37    public function lookup(?string $ipAddress): array {
38        $sourceIp = is_string($ipAddress) ? trim($ipAddress) : '';
39        $sourceIp = $sourceIp === '' ? null : $sourceIp;
40
41        if ($sourceIp === null) {
42            return $this->normalizer->normalizeUnavailable(
43                SignerIpGeolocationUnavailableReason::ADDRESS_UNAVAILABLE,
44                null,
45            );
46        }
47
48        try {
49            $reader = $this->getReader();
50            if ($reader === null) {
51                return $this->normalizer->normalizeUnavailable(
52                    SignerIpGeolocationUnavailableReason::DATABASE_NOT_READY,
53                    $sourceIp,
54                );
55            }
56
57            $city = $reader->city($sourceIp);
58            return $this->normalizer->normalizeResolved($city, $sourceIp);
59        } catch (AddressNotFoundException) {
60            return $this->normalizer->normalizeNotFound($sourceIp);
61        } catch (\Throwable $exception) {
62            $this->logger->warning('GeoIP lookup failed', [
63                'exception' => $exception::class,
64            ]);
65            return $this->normalizer->normalizeUnavailable(
66                SignerIpGeolocationUnavailableReason::LOOKUP_FAILED,
67                $sourceIp,
68            );
69        }
70    }
71
72    private function getReader(): ?Reader {
73        $path = $this->geoIpConfigService->getDatabasePath();
74        if ($path === null) {
75            $this->closeReader();
76            return null;
77        }
78
79        if ($this->reader !== null && $this->openedPath === $path) {
80            return $this->reader;
81        }
82
83        $this->closeReader();
84
85        try {
86            $reader = new Reader($path);
87            $databaseType = $reader->metadata()->databaseType;
88            if (!$this->geoIpDatabaseStatusService->isSupportedDatabaseType($databaseType)) {
89                $reader->close();
90                return null;
91            }
92
93            $this->reader = $reader;
94            $this->openedPath = $path;
95            return $this->reader;
96        } catch (\Throwable $exception) {
97            $this->logger->warning('GeoIP database reader could not be opened', [
98                'exception' => $exception::class,
99            ]);
100            return null;
101        }
102    }
103
104    public function __destruct() {
105        $this->closeReader();
106    }
107
108    private function closeReader(): void {
109        if ($this->reader !== null) {
110            try {
111                $this->reader->close();
112            } catch (\Throwable) {
113                // Ignore close failures on request shutdown.
114            }
115        }
116        $this->reader = null;
117        $this->openedPath = null;
118    }
119}