Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403]

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