config = $analyticsConfig; $cacheDir = dirname(__DIR__, 3) . '/admin/storage/cache'; $this->cache = new FileCache($cacheDir); } /** * Resolves een 2-letter ISO alpha-2 landcode uit een IP-adres. * * De gekozen provider wordt gelezen uit de configuratie. Private en * gereserveerde ranges worden direct afgewezen. MMDB en API vallen * terug op de lokale database bij een mislukte lookup. * * @since 2.6.5 * * @param string $ip IPv4- of IPv6-adres. * @return string|null Landcode in hoofdletters, of null bij onoplosbare/private adressen. */ public function lookupCountry(string $ip): ?string { $ip = trim($ip); if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { return null; } $provider = $this->config['geoip_provider'] ?? 'local'; switch ($provider) { case 'mmdb': $mmdbPath = $this->config['geoip_mmdb_path'] ?? ''; if ($mmdbPath !== '' && file_exists($mmdbPath)) { $code = $this->lookupMMDB($ip, $mmdbPath); if ($code !== null) return self::normalizeCode($code); } // Fallback to local return self::normalizeCode($this->lookupLocal($ip)); case 'api': $code = $this->lookupApi($ip); if ($code !== null) return self::normalizeCode($code); // Fallback to local return self::normalizeCode($this->lookupLocal($ip)); case 'local': default: return self::normalizeCode($this->lookupLocal($ip)); } } /** * Normaliseert een landcode en verwerpt placeholder codes. * * Geldige codes zijn exact twee letters. De codes ZZ en XX worden * beschouwd als onbekend en resulteren in null. * * @since 2.6.5 * * @param string|null $code Ruwe landcode uit een provider. * @return string|null Genormaliseerde landcode of null indien ongeldig/onbekend. */ private static function normalizeCode(?string $code): ?string { if ($code === null) return null; $code = strtoupper(trim($code)); if (!preg_match('/^[A-Z]{2}$/', $code)) return null; if (in_array($code, ['ZZ', 'XX'], true)) return null; return $code; } /** * Binaire zoekopdracht in lokale DB-IP IPv4/IPv6 binaire bestanden. * * Leest de bestanden uit `admin/storage/geoip` en voert een binaire * zoekopdracht uit op basis van het IP-adres. Ondersteunt zowel IPv4 * (10-byte records) als IPv6 (34-byte records). * * @since 2.6.5 * * @param string $ip IPv4- of IPv6-adres. * @return string|null Landcode in hoofdletters of null indien niet gevonden. */ public function lookupLocal(string $ip): ?string { $baseDir = dirname(__DIR__, 3) . '/admin/storage/geoip'; if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { $binPath = $baseDir . '/ipv4.bin'; if (!file_exists($binPath)) return null; $ipLong = sprintf('%u', ip2long($ip)); $recordSize = 10; // 4 bytes start_ip, 4 bytes end_ip, 2 bytes country $fileSize = filesize($binPath); if ($fileSize < $recordSize) return null; $totalRecords = (int)($fileSize / $recordSize); $low = 0; $high = $totalRecords - 1; $handle = @fopen($binPath, 'rb'); if (!$handle) return null; while ($low <= $high) { $mid = (int)(($low + $high) / 2); fseek($handle, $mid * $recordSize); $data = fread($handle, $recordSize); if (strlen($data) < $recordSize) break; $unpacked = unpack('Nstart/Nend/a2country', $data); $start = sprintf('%u', $unpacked['start']); $end = sprintf('%u', $unpacked['end']); if ($ipLong >= $start && $ipLong <= $end) { fclose($handle); return strtoupper($unpacked['country']); } if ($ipLong < $start) { $high = $mid - 1; } else { $low = $mid + 1; } } fclose($handle); return null; } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { $binPath = $baseDir . '/ipv6.bin'; if (!file_exists($binPath)) return null; $ipBin = inet_pton($ip); if ($ipBin === false || strlen($ipBin) !== 16) return null; $recordSize = 34; // 16 bytes start, 16 bytes end, 2 bytes country $fileSize = filesize($binPath); if ($fileSize < $recordSize) return null; $totalRecords = (int)($fileSize / $recordSize); $low = 0; $high = $totalRecords - 1; $handle = @fopen($binPath, 'rb'); if (!$handle) return null; while ($low <= $high) { $mid = (int)(($low + $high) / 2); fseek($handle, $mid * $recordSize); $data = fread($handle, $recordSize); if (strlen($data) < $recordSize) break; $startBin = substr($data, 0, 16); $endBin = substr($data, 16, 16); $country = substr($data, 32, 2); if (strcmp($ipBin, $startBin) >= 0 && strcmp($ipBin, $endBin) <= 0) { fclose($handle); return strtoupper($country); } if (strcmp($ipBin, $startBin) < 0) { $high = $mid - 1; } else { $low = $mid + 1; } } fclose($handle); return null; } return null; } /** * Externe API-lookup met caching. * * Stuurt een HTTP-verzoek naar de geconfigureerde GeoIP API en cachet * het resultaat zeven dagen in een FileCache. De API-URL mag een * optionele API-key bevatten. * * @since 2.6.5 * * @param string $ip IPv4- of IPv6-adres. * @return string|null Landcode of null indien de lookup faalt. */ private function lookupApi(string $ip): ?string { $cacheKey = 'geoip_api_' . md5($ip); if ($this->cache->has($cacheKey)) { return $this->cache->get($cacheKey); } $apiUrl = $this->config['geoip_api_url'] ?? 'http://ip-api.com/json/{ip}?fields=countryCode'; $apiUrl = str_replace('{ip}', urlencode($ip), $apiUrl); if (!empty($this->config['geoip_api_key'])) { $apiUrl .= (str_contains($apiUrl, '?') ? '&' : '?') . 'key=' . urlencode($this->config['geoip_api_key']); } $ctx = stream_context_create(['http' => ['timeout' => 3, 'user_agent' => 'CodePressCMS/1.9.0']]); $response = @file_get_contents($apiUrl, false, $ctx); if ($response) { $json = json_decode($response, true); $code = $json['countryCode'] ?? $json['country_code'] ?? null; if ($code && strlen($code) === 2) { $code = strtoupper($code); $this->cache->set($cacheKey, $code, 86400 * 7); // Cache for 24h * 7 return $code; } } return null; } /** * Pure-PHP MaxMind MMDB reader. * * Opent een MMDB-bestand en extraheert de landcode via de ingebouwde * MMDBReader. Fouten worden gesilenced en resulteren in null. * * @since 2.6.5 * * @param string $ip IPv4- of IPv6-adres. * @param string $filePath Pad naar het MMDB-bestand. * @return string|null Landcode of null bij een fout. */ private function lookupMMDB(string $ip, string $filePath): ?string { try { $reader = new MMDBReader($filePath); $record = $reader->get($ip); return $record['country']['iso_code'] ?? $record['registered_country']['iso_code'] ?? null; } catch (\Throwable $e) { return null; } } /** * Zet een 2-letter ISO landcode om naar een regionale vlag-emoji. * * @since 2.6.5 * * @param string|null $code Landcode of null. * @return string Vlag-emoji of een wereldbol bij ongeldige invoer. */ public static function getCountryFlagEmoji(?string $code): string { if (!$code || strlen($code) !== 2) { return '🌐'; } $code = strtoupper($code); $first = ord($code[0]) - 65 + 0x1F1E6; $second = ord($code[1]) - 65 + 0x1F1E6; return mb_chr($first, 'UTF-8') . mb_chr($second, 'UTF-8'); } /** * Geeft de landnaam in Nederlands of Engels. * * Bevat een statische mapping van de meest voorkomende landcodes naar * hun naam in de gevraagde taal. Onbekende codes worden ongewijzigd * teruggegeven. * * @since 2.6.5 * * @param string|null $code Landcode of null. * @param string $lang Gewenste taal, 'nl' of 'en'. Default 'nl'. * @return string Landnaam in de gevraagde taal of de code zelf. */ public static function getCountryName(?string $code, string $lang = 'nl'): string { if (!$code) return 'Lokaal / Onbekend'; $code = strtoupper($code); $names = [ 'NL' => ['nl' => 'Nederland', 'en' => 'Netherlands'], 'BE' => ['nl' => 'België', 'en' => 'Belgium'], 'DE' => ['nl' => 'Duitsland', 'en' => 'Germany'], 'FR' => ['nl' => 'Frankrijk', 'en' => 'France'], 'GB' => ['nl' => 'Verenigd Koninkrijk', 'en' => 'United Kingdom'], 'US' => ['nl' => 'Verenigde Staten', 'en' => 'United States'], 'CA' => ['nl' => 'Canada', 'en' => 'Canada'], 'ES' => ['nl' => 'Spanje', 'en' => 'Spain'], 'IT' => ['nl' => 'Italië', 'en' => 'Italy'], 'PL' => ['nl' => 'Polen', 'en' => 'Poland'], 'AT' => ['nl' => 'Oostenrijk', 'en' => 'Austria'], 'CH' => ['nl' => 'Zwitserland', 'en' => 'Switzerland'], 'SE' => ['nl' => 'Zweden', 'en' => 'Sweden'], 'NO' => ['nl' => 'Noorwegen', 'en' => 'Norway'], 'DK' => ['nl' => 'Denemarken', 'en' => 'Denmark'], 'FI' => ['nl' => 'Finland', 'en' => 'Finland'], 'IE' => ['nl' => 'Ierland', 'en' => 'Ireland'], 'PT' => ['nl' => 'Portugal', 'en' => 'Portugal'], 'GR' => ['nl' => 'Griekenland', 'en' => 'Greece'], 'CZ' => ['nl' => 'Tsjechië', 'en' => 'Czechia'], 'CN' => ['nl' => 'China', 'en' => 'China'], 'JP' => ['nl' => 'Japan', 'en' => 'Japan'], 'IN' => ['nl' => 'India', 'en' => 'India'], 'BR' => ['nl' => 'Brazilië', 'en' => 'Brazil'], 'AU' => ['nl' => 'Australië', 'en' => 'Australia'], 'RU' => ['nl' => 'Rusland', 'en' => 'Russia'], 'ZA' => ['nl' => 'Zuid-Afrika', 'en' => 'South Africa'], 'TR' => ['nl' => 'Turkije', 'en' => 'Turkey'], 'UA' => ['nl' => 'Oekraïne', 'en' => 'Ukraine'], 'MX' => ['nl' => 'Mexico', 'en' => 'Mexico'], 'ID' => ['nl' => 'Indonesië', 'en' => 'Indonesia'], 'SG' => ['nl' => 'Singapore', 'en' => 'Singapore'], 'KR' => ['nl' => 'Zuid-Korea', 'en' => 'South Korea'], 'AR' => ['nl' => 'Argentinië', 'en' => 'Argentina'], ]; if (isset($names[$code][$lang])) { return $names[$code][$lang]; } return $code; } } /** * Ingebouwde pure-PHP MaxMind DB reader. * * Minimale implementatie van de MaxMind DB binary reader, nodig omdat de * externe GeoIP2 dependency optioneel is. Ondersteunt 28-bit record sizes * en de veelvoorkomende datatypes (pointer, string, map, uint, bool). * * @since 2.6.5 */ class MMDBReader { /** * Pad naar het MMDB-bestand. * * @since 2.6.5 * @var string */ private string $file; /** * Bestandshandle voor het MMDB-bestand. * * @since 2.6.5 * @var resource|null */ private $handle; /** * Metadata uit de MMDB-header. * * @since 2.6.5 * @var array Bevat o.a. node_count, record_size en ip_version. */ private array $meta; /** * Opent het MMDB-bestand en laadt de metadata. * * @since 2.6.5 * * @param string $file Pad naar het MMDB-bestand. * @throws \InvalidArgumentException Als het bestand niet bestaat. * @throws \RuntimeException Als het bestandformaat ongeldig is. */ public function __construct(string $file) { if (!file_exists($file)) { throw new \InvalidArgumentException("MMDB file does not exist: {$file}"); } $this->file = $file; $this->handle = fopen($file, 'rb'); $this->loadMetadata(); } /** * Sluit de bestandshandle bij het vernietigen van de instantie. * * @since 2.6.5 */ public function __destruct() { if ($this->handle) { fclose($this->handle); } } /** * Laadt de MMDB-metadata vanaf het einde van het bestand. * * Zoekt naar de MaxMind marker en decodeert de daaropvolgende data. * * @since 2.6.5 * * @throws \RuntimeException Als de marker niet gevonden wordt. * @return void */ private function loadMetadata(): void { $stat = fstat($this->handle); $size = $stat['size']; $marker = "\xab\xcd\xefMaxMind.com\x01"; fseek($this->handle, max(0, $size - 128000)); $buffer = fread($this->handle, 128000); $pos = strrpos($buffer, $marker); if ($pos === false) { throw new \RuntimeException("Invalid MMDB file format: {$this->file}"); } $metaOffset = $size - 128000 + $pos + strlen($marker); fseek($this->handle, $metaOffset); $this->meta = $this->decodeData($metaOffset)[0]; } /** * Voert een lookup uit voor een IP-adres in de MMDB-boom. * * Werkt door de binaire boom te doorlopen op basis van de bits van het * IP-adres. IPv4-adressen in een IPv6-boom worden correct afgehandeld * via de ipv4_instance_count metadata. * * @since 2.6.5 * * @param string $ip IPv4- of IPv6-adres. * @return array|null Gedecodeerde datarecord of null indien niet gevonden. */ public function get(string $ip): ?array { $ipBin = inet_pton($ip); if ($ipBin === false) return null; $isV4 = strlen($ipBin) === 4; $nodeCount = $this->meta['node_count'] ?? 0; $recordSize = $this->meta['record_size'] ?? 28; $ipVersion = $this->meta['ip_version'] ?? 6; // Start node search $node = 0; $bitLength = $isV4 ? 32 : 128; // If IPv4 in IPv6 tree if ($isV4 && $ipVersion === 6) { $node = $this->meta['ipv4_instance_count'] ?? 0; } for ($i = 0; $i < $bitLength; $i++) { if ($node >= $nodeCount) break; $byteIndex = (int)($i / 8); $bit = (ord($ipBin[$byteIndex]) >> (7 - ($i % 8))) & 1; $node = $this->readNode($node, $bit, $recordSize); } if ($node >= $nodeCount) { $dataOffset = $node - $nodeCount + ($nodeCount * ($recordSize * 2 / 8)) + 16; return $this->decodeData($dataOffset)[0]; } return null; } /** * Leest een kind-node (left/right) uit de MMDB-boom. * * Ondersteunt uitsluitend 28-bit record sizes. * * @since 2.6.5 * * @param int $node Huidig node-index. * @param int $bit Bitwaarde (0 voor left, 1 voor right). * @param int $recordSize Record size in bits. * @return int Doel-node-index of 0 bij niet-ondersteunde record sizes. */ private function readNode(int $node, int $bit, int $recordSize): int { $bytesPerRecord = $recordSize / 4; // 28-bit -> 3.5 bytes per record $nodeOffset = (int)($node * $recordSize * 2 / 8); fseek($this->handle, $nodeOffset); $bytes = fread($this->handle, 8); if ($recordSize === 28) { $left = (ord($bytes[0]) << 16) | (ord($bytes[1]) << 8) | ord($bytes[2]) | ((ord($bytes[3]) & 0xf0) << 20); $right = (ord($bytes[4]) << 16) | (ord($bytes[5]) << 8) | ord($bytes[6]) | ((ord($bytes[3]) & 0x0f) << 24); return $bit === 0 ? $left : $right; } return 0; } /** * Decodeert een MMDB-datarecord vanaf de gegeven offset. * * Ondersteunt pointer, string, double, uint, map en bool. De array * return value bevat de gedecodeerde waarde en de cursorpositie na * het record. * * @since 2.6.5 * * @param int $offset Start-offset in het bestand. * @return array Pair [mixed $value, int $newOffset]. */ private function decodeData(int $offset): array { fseek($this->handle, $offset); $ctrl = ord(fread($this->handle, 1)); $type = $ctrl >> 5; $size = $ctrl & 0x1f; if ($type === 0) { $type = ord(fread($this->handle, 1)) + 7; } if ($size >= 29) { $bytesToRead = $size - 28; $extSize = 0; for ($i = 0; $i < $bytesToRead; $i++) { $extSize = ($extSize << 8) | ord(fread($this->handle, 1)); } $size = $extSize + 29; if ($bytesToRead === 1) $size += 0; elseif ($bytesToRead === 2) $size += 248; elseif ($bytesToRead === 3) $size += 65816; } switch ($type) { case 1: // Pointer return [$this->decodeData(ftell($this->handle) + $size)[0], ftell($this->handle)]; case 2: // String return [fread($this->handle, $size), ftell($this->handle)]; case 3: // Double return [0.0, ftell($this->handle)]; case 5: // Uint32/64 $val = 0; for ($i = 0; $i < $size; $i++) { $val = ($val << 8) | ord(fread($this->handle, 1)); } return [$val, ftell($this->handle)]; case 7: // Map $map = []; for ($i = 0; $i < $size; $i++) { [$key, ] = $this->decodeData(ftell($this->handle)); [$val, ] = $this->decodeData(ftell($this->handle)); $map[$key] = $val; } return [$map, ftell($this->handle)]; case 11: // Bool return [$size === 1, ftell($this->handle)]; default: return [null, ftell($this->handle)]; } } }