config = $analyticsConfig; $cacheDir = dirname(__DIR__, 3) . '/admin/storage/cache'; $this->cache = new FileCache($cacheDir); } /** * Resolve country code (2-letter ISO alpha-2, upper-case) from an IP address * * @param string $ip IPv4 or IPv6 address * @return string|null Country code or null if unresolved/private */ 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)); } } /** * Normalize a country code; placeholder codes (ZZ/XX) count as unknown */ 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; } /** * Binary search lookup in local DB-IP IPv4/IPv6 binary files */ 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; } /** * External API lookup with caching */ 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 */ 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; } } /** * Convert 2-letter ISO country code to regional indicator flag emoji */ 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'); } /** * Get country name in Dutch or English */ 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; } } /** * Built-in pure-PHP MaxMind DB Reader */ class MMDBReader { private string $file; private $handle; private array $meta; 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(); } public function __destruct() { if ($this->handle) { fclose($this->handle); } } 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]; } 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; } 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; } 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)]; } } }