CodePress CMS v1.9.0: visitor statistics with SVG world map and GeoIP

- Add GeoIP class with provider chain: local DB-IP Lite, MaxMind .mmdb, external API
- Add built-in pure-PHP MMDBReader so .mmdb works without Composer
- Add cli/geoip-update.php to download DB-IP Lite and build a compact binary index
- Add cli/generate-world-map.php to generate the world map SVG from Natural Earth TopoJSON
- Add Analytics class aggregating stats in admin/storage/stats.json with LOCK_EX
- Add admin statistics page with choropleth world map, country list, top pages,
  daily chart, referrers and a period filter
- Add GeoIP and privacy settings with database update and stats reset buttons
- Add optional IP anonymization and configurable retention period
- Add country field to requests.log (parser accepts 7, 8 or 9 fields)
- Add country column to request log and KPI cards to the dashboard
- Ignore GeoIP binaries and stats.json in Git
- Update TODO.md, guides and version to 1.9.0
This commit is contained in:
2026-07-29 15:40:02 +02:00
parent 8c6b38c2c5
commit 06785e9922
20 changed files with 1804 additions and 11 deletions
+425
View File
@@ -0,0 +1,425 @@
<?php
/**
* GeoIP - Country lookup provider chain (Local binary, MMDB, and API)
*/
class GeoIP
{
private array $config;
private ?FileCache $cache = null;
public function __construct(array $analyticsConfig = [])
{
$this->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)];
}
}
}