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 a795307664
commit 5357bc8915
20 changed files with 1804 additions and 11 deletions
+26 -3
View File
@@ -9,7 +9,7 @@ class RequestLogger
$this->logFile = $logFile;
}
public function log(string $page, string $ip, string $userAgent, string $referrer, string $host, string $acceptLanguage, string $status = 'ok'): void
public function log(string $page, string $ip, string $userAgent, string $referrer, string $host, string $acceptLanguage, string $status = 'ok', ?string $country = null): void
{
$dir = dirname($this->logFile);
if (!is_dir($dir)) {
@@ -19,10 +19,31 @@ class RequestLogger
$timestamp = date('Y-m-d H:i:s');
$ua = substr(preg_replace('/[[:cntrl:]]/', '', $userAgent), 0, 500);
$ref = substr(preg_replace('/[[:cntrl:]]/', '', $referrer), 0, 500);
$line = "[{$timestamp}] [{$ip}] [{$host}] [{$acceptLanguage}] [{$page}] [{$ua}] [{$ref}] [{$status}]\n";
$cc = ($country && strlen($country) === 2) ? strtoupper($country) : '';
$line = "[{$timestamp}] [{$ip}] [{$host}] [{$acceptLanguage}] [{$page}] [{$ua}] [{$ref}] [{$status}] [{$cc}]\n";
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
/**
* Mask the last octet (IPv4) or last block (IPv6) of an IP address
*/
public static function anonymizeIp(string $ip): string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
if (count($parts) === 4) {
$parts[3] = 'x';
return implode('.', $parts);
}
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$parts = explode(':', $ip);
$keep = array_slice($parts, 0, 4);
return implode(':', $keep) . '::x';
}
return $ip;
}
public static function getClientIp(): string
{
$headerKeys = [
@@ -132,13 +153,14 @@ class RequestLogger
foreach ($content as $line) {
$trimmed = trim($line);
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]*)\](?: \[([^\]]*)\])?$/', $trimmed, $m)) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]*)\](?: \[([^\]]*)\])?(?: \[([^\]]*)\])?$/', $trimmed, $m)) {
$user = $m[3];
if (str_contains($user, '.') || str_contains($user, ':') || $user === 'cli') {
$user = 'Gast';
}
$status = $m[8] ?? 'ok';
if ($status === '') $status = 'ok';
$country = $m[9] ?? '';
$visitorInfo = self::detectVisitorInfo($m[6], $user);
$logs[] = [
@@ -151,6 +173,7 @@ class RequestLogger
'ua' => $m[6],
'referrer' => $m[7],
'status' => $status,
'country' => $country,
];
}
}