file = $file; $size = (int) fstat( $file )['size']; $tail = min( $size, 128 * 1024 ); $chunk = $this->read( $size - $tail, $tail ); $at = strrpos( $chunk, self::METADATA_MARKER ); if ( false === $at ) { throw new \RuntimeException( 'The file is not a MaxMind DB.' ); } $position = $size - $tail + $at + strlen( self::METADATA_MARKER ); $this->metadata = (array) $this->decode( $position, $position )[0]; $this->nodes = (int) ( $this->metadata['node_count'] ?? 0 ); $this->record_size = (int) ( $this->metadata['record_size'] ?? 0 ); if ( ! $this->nodes || ! in_array( $this->record_size, array( 24, 28, 32 ), true ) ) { throw new \RuntimeException( 'The database has an unknown layout.' ); } $this->data_start = (int) ( $this->record_size * 2 / 8 * $this->nodes ) + 16; } /** * Closes the file. */ public function __destruct() { if ( is_resource( $this->file ) ) { fclose( $this->file ); } } /** * Description of the database: type, languages, build_epoch and more. * * @return array */ public function metadata() { return $this->metadata; } /** * Record of an IP address. * * @param string $ip IPv4 or IPv6 address. * @return array|null Null when the database has no record for it. */ public function get( $ip ) { $packed = filter_var( $ip, FILTER_VALIDATE_IP ) ? inet_pton( $ip ) : false; if ( false === $packed ) { return null; } $bits = strlen( $packed ) * 8; $node = 4 === strlen( $packed ) && 6 === (int) ( $this->metadata['ip_version'] ?? 4 ) ? $this->ipv4_start() : 0; for ( $i = 0; $i < $bits && $node < $this->nodes; $i++ ) { $bit = 1 & ( ord( $packed[ $i >> 3 ] ) >> ( 7 - ( $i % 8 ) ) ); $node = $this->record( $node, $bit ); } if ( $node <= $this->nodes ) { return null; } $position = $node - $this->nodes + $this->data_start - 16; $value = $this->decode( $position, $this->data_start )[0]; return is_array( $value ) ? $value : null; } /** * Node of ::/96, where IPv4 addresses are kept in a database for IPv6. * * @return int */ private function ipv4_start() { if ( null === $this->ipv4_node ) { $node = 0; for ( $i = 0; $i < 96 && $node < $this->nodes; $i++ ) { $node = $this->record( $node, 0 ); } $this->ipv4_node = $node; } return $this->ipv4_node; } /** * Left or right record of a node. * * @param int $node Node. * @param int $right 1 for the right record. * @return int */ private function record( $node, $right ) { switch ( $this->record_size ) { case 24: $bytes = $this->read( $node * 6 + $right * 3, 3 ); return unpack( 'N', "\0" . $bytes )[1]; case 28: $bytes = $this->read( $node * 7, 7 ); $middle = ord( $bytes[3] ); if ( $right ) { return ( ( $middle & 0x0F ) << 24 ) | unpack( 'N', "\0" . substr( $bytes, 4, 3 ) )[1]; } return ( ( $middle & 0xF0 ) << 20 ) | unpack( 'N', "\0" . substr( $bytes, 0, 3 ) )[1]; default: return unpack( 'N', $this->read( $node * 8 + $right * 4, 4 ) )[1]; } } /** * Decodes one value of the data section. * * @param int $position Where the value starts. * @param int $base Start of the section that pointers count from. * @return array{0: mixed, 1: int} Value and the position after it. * @throws \RuntimeException On a type that is not part of the format. */ private function decode( $position, $base ) { $control = ord( $this->read( $position, 1 ) ); $type = $control >> 5; ++$position; if ( 1 === $type ) { list( $target, $position ) = $this->pointer( $control, $position ); return array( $this->decode( $base + $target, $base )[0], $position ); } if ( 0 === $type ) { $type = 7 + ord( $this->read( $position, 1 ) ); ++$position; } list( $size, $position ) = $this->size( $control & 0x1F, $position ); switch ( $type ) { case 2: return array( $size ? $this->read( $position, $size ) : '', $position + $size ); case 3: return array( unpack( 'E', $this->read( $position, 8 ) )[1], $position + 8 ); case 4: return array( $size ? $this->read( $position, $size ) : '', $position + $size ); case 5: case 6: case 9: case 10: return array( $this->unsigned( $position, $size ), $position + $size ); case 7: $map = array(); for ( $i = 0; $i < $size; $i++ ) { list( $key, $position ) = $this->decode( $position, $base ); list( $value, $position ) = $this->decode( $position, $base ); $map[ (string) $key ] = $value; } return array( $map, $position ); case 8: $value = $this->unsigned( $position, $size ); if ( 4 === $size && $value >= 0x80000000 ) { $value -= 0x100000000; } return array( (int) $value, $position + $size ); case 11: $list = array(); for ( $i = 0; $i < $size; $i++ ) { list( $list[], $position ) = $this->decode( $position, $base ); } return array( $list, $position ); case 14: return array( (bool) $size, $position ); case 15: return array( unpack( 'G', $this->read( $position, 4 ) )[1], $position + 4 ); } throw new \RuntimeException( 'Unknown data type in the database.' ); } /** * Target of a pointer, counted from the start of the data section. * * @param int $control Control byte. * @param int $position Position after the control byte. * @return array{0: int, 1: int} Target and the position after the pointer. */ private function pointer( $control, $position ) { $length = ( ( $control >> 3 ) & 0x3 ) + 1; $bytes = $this->read( $position, $length ); $high = $control & 0x7; switch ( $length ) { case 1: $target = ( $high << 8 ) | ord( $bytes ); break; case 2: $target = ( ( $high << 16 ) | unpack( 'n', $bytes )[1] ) + 2048; break; case 3: $target = ( ( $high << 24 ) | unpack( 'N', "\0" . $bytes )[1] ) + 526336; break; default: $target = unpack( 'N', $bytes )[1]; } return array( $target, $position + $length ); } /** * Size of a value: up to 28 in the control byte, larger sizes in the bytes after it. * * @param int $size Size bits of the control byte. * @param int $position Position after the control byte and the extended type. * @return array{0: int, 1: int} Size and the position of the value. */ private function size( $size, $position ) { if ( $size < 29 ) { return array( $size, $position ); } $length = $size - 28; $value = unpack( 'N', str_pad( $this->read( $position, $length ), 4, "\0", STR_PAD_LEFT ) )[1]; $offset = array( 1 => 29, 2 => 285, 3 => 65821, ); return array( $offset[ $length ] + $value, $position + $length ); } /** * Unsigned big-endian number. Numbers above PHP_INT_MAX are returned as text. * * @param int $position Start. * @param int $size Bytes, at most 16. * @return int|string */ private function unsigned( $position, $size ) { $value = 0; foreach ( str_split( $size ? $this->read( $position, $size ) : '' ) as $byte ) { if ( $value > ( PHP_INT_MAX >> 8 ) ) { return function_exists( 'gmp_strval' ) ? gmp_strval( gmp_import( $this->read( $position, $size ) ) ) : (string) PHP_INT_MAX; } $value = ( $value << 8 ) | ord( $byte ); } return $value; } /** * Bytes of the file. * * @param int $position Start. * @param int $length Length. * @return string * @throws \RuntimeException When the file ends too early. */ private function read( $position, $length ) { if ( $length <= 0 ) { return ''; } fseek( $this->file, $position ); $bytes = fread( $this->file, $length ); if ( false === $bytes || strlen( $bytes ) !== $length ) { throw new \RuntimeException( 'The database ends too early.' ); } return $bytes; } }