MDL-48766 lib: Import MaxMind GeoIP2 PHP API
[moodle.git] / lib / maxmind / MaxMind / Db / Reader.php
CommitLineData
eacc36a2
DP
1<?php
2
3namespace MaxMind\Db;
4
5use MaxMind\Db\Reader\Decoder;
6use MaxMind\Db\Reader\InvalidDatabaseException;
7use MaxMind\Db\Reader\Metadata;
8use MaxMind\Db\Reader\Util;
9
10/**
11 * Instances of this class provide a reader for the MaxMind DB format. IP
12 * addresses can be looked up using the <code>get</code> method.
13 */
14class Reader
15{
16 private static $DATA_SECTION_SEPARATOR_SIZE = 16;
17 private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com";
18 private static $METADATA_START_MARKER_LENGTH = 14;
19
20 private $decoder;
21 private $fileHandle;
22 private $fileSize;
23 private $ipV4Start;
24 private $metadata;
25
26 /**
27 * Constructs a Reader for the MaxMind DB format. The file passed to it must
28 * be a valid MaxMind DB file such as a GeoIp2 database file.
29 *
30 * @param string $database
31 * the MaxMind DB file to use.
32 * @throws \InvalidArgumentException for invalid database path or unknown arguments
33 * @throws \MaxMind\Db\Reader\InvalidDatabaseException
34 * if the database is invalid or there is an error reading
35 * from it.
36 */
37 public function __construct($database)
38 {
39 if (func_num_args() != 1) {
40 throw new \InvalidArgumentException(
41 'The constructor takes exactly one argument.'
42 );
43 }
44
45 if (!is_readable($database)) {
46 throw new \InvalidArgumentException(
47 "The file \"$database\" does not exist or is not readable."
48 );
49 }
50 $this->fileHandle = @fopen($database, 'rb');
51 if ($this->fileHandle === false) {
52 throw new \InvalidArgumentException(
53 "Error opening \"$database\"."
54 );
55 }
56 $this->fileSize = @filesize($database);
57 if ($this->fileSize === false) {
58 throw new \UnexpectedValueException(
59 "Error determining the size of \"$database\"."
60 );
61 }
62
63 $start = $this->findMetadataStart($database);
64 $metadataDecoder = new Decoder($this->fileHandle, $start);
65 list($metadataArray) = $metadataDecoder->decode($start);
66 $this->metadata = new Metadata($metadataArray);
67 $this->decoder = new Decoder(
68 $this->fileHandle,
69 $this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE
70 );
71 }
72
73 /**
74 * Looks up the <code>address</code> in the MaxMind DB.
75 *
76 * @param string $ipAddress
77 * the IP address to look up.
78 * @return array the record for the IP address.
79 * @throws \BadMethodCallException if this method is called on a closed database.
80 * @throws \InvalidArgumentException if something other than a single IP address is passed to the method.
81 * @throws InvalidDatabaseException
82 * if the database is invalid or there is an error reading
83 * from it.
84 */
85 public function get($ipAddress)
86 {
87 if (func_num_args() != 1) {
88 throw new \InvalidArgumentException(
89 'Method takes exactly one argument.'
90 );
91 }
92
93 if (!is_resource($this->fileHandle)) {
94 throw new \BadMethodCallException(
95 'Attempt to read from a closed MaxMind DB.'
96 );
97 }
98
99 if (!filter_var($ipAddress, FILTER_VALIDATE_IP)) {
100 throw new \InvalidArgumentException(
101 "The value \"$ipAddress\" is not a valid IP address."
102 );
103 }
104
105 if ($this->metadata->ipVersion == 4 && strrpos($ipAddress, ':')) {
106 throw new \InvalidArgumentException(
107 "Error looking up $ipAddress. You attempted to look up an"
108 . " IPv6 address in an IPv4-only database."
109 );
110 }
111 $pointer = $this->findAddressInTree($ipAddress);
112 if ($pointer == 0) {
113 return null;
114 }
115 return $this->resolveDataPointer($pointer);
116 }
117
118 private function findAddressInTree($ipAddress)
119 {
120 // XXX - could simplify. Done as a byte array to ease porting
121 $rawAddress = array_merge(unpack('C*', inet_pton($ipAddress)));
122
123 $bitCount = count($rawAddress) * 8;
124
125 // The first node of the tree is always node 0, at the beginning of the
126 // value
127 $node = $this->startNode($bitCount);
128
129 for ($i = 0; $i < $bitCount; $i++) {
130 if ($node >= $this->metadata->nodeCount) {
131 break;
132 }
133 $tempBit = 0xFF & $rawAddress[$i >> 3];
134 $bit = 1 & ($tempBit >> 7 - ($i % 8));
135
136 $node = $this->readNode($node, $bit);
137 }
138 if ($node == $this->metadata->nodeCount) {
139 // Record is empty
140 return 0;
141 } elseif ($node > $this->metadata->nodeCount) {
142 // Record is a data pointer
143 return $node;
144 }
145 throw new InvalidDatabaseException("Something bad happened");
146 }
147
148
149 private function startNode($length)
150 {
151 // Check if we are looking up an IPv4 address in an IPv6 tree. If this
152 // is the case, we can skip over the first 96 nodes.
153 if ($this->metadata->ipVersion == 6 && $length == 32) {
154 return $this->ipV4StartNode();
155 }
156 // The first node of the tree is always node 0, at the beginning of the
157 // value
158 return 0;
159 }
160
161 private function ipV4StartNode()
162 {
163 // This is a defensive check. There is no reason to call this when you
164 // have an IPv4 tree.
165 if ($this->metadata->ipVersion == 4) {
166 return 0;
167 }
168
169 if ($this->ipV4Start != 0) {
170 return $this->ipV4Start;
171 }
172 $node = 0;
173
174 for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; $i++) {
175 $node = $this->readNode($node, 0);
176 }
177 $this->ipV4Start = $node;
178 return $node;
179 }
180
181 private function readNode($nodeNumber, $index)
182 {
183 $baseOffset = $nodeNumber * $this->metadata->nodeByteSize;
184
185 // XXX - probably could condense this.
186 switch ($this->metadata->recordSize) {
187 case 24:
188 $bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3);
189 list(, $node) = unpack('N', "\x00" . $bytes);
190 return $node;
191 case 28:
192 $middleByte = Util::read($this->fileHandle, $baseOffset + 3, 1);
193 list(, $middle) = unpack('C', $middleByte);
194 if ($index == 0) {
195 $middle = (0xF0 & $middle) >> 4;
196 } else {
197 $middle = 0x0F & $middle;
198 }
199 $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 3);
200 list(, $node) = unpack('N', chr($middle) . $bytes);
201 return $node;
202 case 32:
203 $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4);
204 list(, $node) = unpack('N', $bytes);
205 return $node;
206 default:
207 throw new InvalidDatabaseException(
208 'Unknown record size: '
209 . $this->metadata->recordSize
210 );
211 }
212 }
213
214 private function resolveDataPointer($pointer)
215 {
216 $resolved = $pointer - $this->metadata->nodeCount
217 + $this->metadata->searchTreeSize;
218 if ($resolved > $this->fileSize) {
219 throw new InvalidDatabaseException(
220 "The MaxMind DB file's search tree is corrupt"
221 );
222 }
223
224 list($data) = $this->decoder->decode($resolved);
225 return $data;
226 }
227
228 /*
229 * This is an extremely naive but reasonably readable implementation. There
230 * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever
231 * an issue, but I suspect it won't be.
232 */
233 private function findMetadataStart($filename)
234 {
235 $handle = $this->fileHandle;
236 $fstat = fstat($handle);
237 $fileSize = $fstat['size'];
238 $marker = self::$METADATA_START_MARKER;
239 $markerLength = self::$METADATA_START_MARKER_LENGTH;
240
241 for ($i = 0; $i < $fileSize - $markerLength + 1; $i++) {
242 for ($j = 0; $j < $markerLength; $j++) {
243 fseek($handle, $fileSize - $i - $j - 1);
244 $matchBit = fgetc($handle);
245 if ($matchBit != $marker[$markerLength - $j - 1]) {
246 continue 2;
247 }
248 }
249 return $fileSize - $i;
250 }
251 throw new InvalidDatabaseException(
252 "Error opening database file ($filename). " .
253 'Is this a valid MaxMind DB file?'
254 );
255 }
256
257 /**
258 * @throws \InvalidArgumentException if arguments are passed to the method.
259 * @throws \BadMethodCallException if the database has been closed.
260 * @return Metadata object for the database.
261 */
262 public function metadata()
263 {
264 if (func_num_args()) {
265 throw new \InvalidArgumentException(
266 'Method takes no arguments.'
267 );
268 }
269
270 // Not technically required, but this makes it consistent with
271 // C extension and it allows us to change our implementation later.
272 if (!is_resource($this->fileHandle)) {
273 throw new \BadMethodCallException(
274 'Attempt to read from a closed MaxMind DB.'
275 );
276 }
277
278 return $this->metadata;
279 }
280
281 /**
282 * Closes the MaxMind DB and returns resources to the system.
283 *
284 * @throws \Exception
285 * if an I/O error occurs.
286 */
287 public function close()
288 {
289 if (!is_resource($this->fileHandle)) {
290 throw new \BadMethodCallException(
291 'Attempt to close a closed MaxMind DB.'
292 );
293 }
294 fclose($this->fileHandle);
295 }
296}