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
+97 -1
View File
@@ -22,6 +22,10 @@ if (file_exists($autoloader)) {
$appConfig = require __DIR__ . '/../admin/config/app.php';
require_once __DIR__ . '/../admin/src/AdminAuth.php';
require_once __DIR__ . '/../cms/core/class/RequestLogger.php';
require_once __DIR__ . '/../cms/core/class/BotGuard.php';
require_once __DIR__ . '/../cms/core/class/Cache.php';
require_once __DIR__ . '/../cms/core/class/GeoIP.php';
require_once __DIR__ . '/../cms/core/class/Analytics.php';
$auth = new AdminAuth($appConfig);
@@ -92,6 +96,10 @@ switch ($route) {
handleSecurity($auth, $appConfig);
break;
case 'statistics':
handleStatistics($auth, $appConfig);
break;
case 'update':
handleUpdate($auth, $appConfig);
break;
@@ -232,6 +240,11 @@ function handleDashboard(AdminAuth $auth, array $config): void
$requestLogger = new RequestLogger($requestLogFile);
$recentRequests = $requestLogger->getLogs(20);
// Analytics summary (last 30 days)
$siteAnalytics = is_array($siteConfig['analytics'] ?? null) ? $siteConfig['analytics'] : [];
$analytics = new Analytics($siteAnalytics);
$analyticsSummary = $analytics->getStats(30);
require __DIR__ . '/../admin/templates/layout.php';
}
@@ -809,6 +822,90 @@ function handleSecurity(AdminAuth $auth, array $config): void
require __DIR__ . '/../admin/templates/layout.php';
}
function handleStatistics(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$configJson = $config['config_json'];
$message = '';
$messageType = '';
require_once __DIR__ . '/../cms/core/class/Cache.php';
require_once __DIR__ . '/../cms/core/class/GeoIP.php';
require_once __DIR__ . '/../cms/core/class/Analytics.php';
require_once __DIR__ . '/../cms/core/class/BotGuard.php';
$configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
if (!is_array($configData)) $configData = [];
$analyticsDefaults = [
'enabled' => true,
'anonymize_ip' => false,
'geoip_provider' => 'local',
'geoip_mmdb_path' => '',
'geoip_api_url' => '',
'geoip_api_key' => '',
'retention_days' => 400,
];
$ana = array_merge($analyticsDefaults, is_array($configData['analytics'] ?? null) ? $configData['analytics'] : []);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} elseif (($_POST['action'] ?? '') === 'update_geoip') {
require_once __DIR__ . '/../cli/geoip-update.php';
$result = updateGeoIPDatabase();
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
adminLog($config, 'info', $user['username'] . ' werkte de GeoIP database bij');
} elseif (($_POST['action'] ?? '') === 'reset_stats') {
$statsPath = $config['codepress_root'] . '/admin/storage/stats.json';
@unlink($statsPath);
adminLog($config, 'warning', $user['username'] . ' wiste alle statistieken');
$message = 'Alle statistieken zijn gewist.';
$messageType = 'success';
} else {
$configData['analytics']['enabled'] = !empty($_POST['analytics_enabled']);
$configData['analytics']['anonymize_ip'] = !empty($_POST['anonymize_ip']);
$provider = $_POST['geoip_provider'] ?? 'local';
$configData['analytics']['geoip_provider'] = in_array($provider, ['local', 'mmdb', 'api'], true) ? $provider : 'local';
$configData['analytics']['geoip_mmdb_path'] = trim($_POST['geoip_mmdb_path'] ?? '');
$configData['analytics']['geoip_api_url'] = trim($_POST['geoip_api_url'] ?? '');
$configData['analytics']['geoip_api_key'] = trim($_POST['geoip_api_key'] ?? '');
$configData['analytics']['retention_days'] = max(30, min(3650, (int)($_POST['retention_days'] ?? 400)));
file_put_contents($configJson, json_encode($configData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' wijzigde statistiek-instellingen');
$message = 'Statistiek-instellingen opgeslagen.';
$messageType = 'success';
$ana = array_merge($analyticsDefaults, $configData['analytics']);
}
}
// Period filter
$period = (int)($_GET['period'] ?? 30);
if (!in_array($period, [7, 30, 90, 0], true)) $period = 30;
$analytics = new Analytics($ana);
$stats = $analytics->getStats($period);
// GeoIP database status
$geoDir = $config['codepress_root'] . '/admin/storage/geoip';
$geoMeta = null;
if (file_exists($geoDir . '/meta.json')) {
$geoMeta = json_decode(file_get_contents($geoDir . '/meta.json'), true);
}
// World map SVG
$worldMapPath = $config['codepress_root'] . '/public/assets/img/world-map.svg';
$worldMapSvg = file_exists($worldMapPath) ? file_get_contents($worldMapPath) : '';
$worldMapSvg = preg_replace('/^<\?xml[^>]*\?>\s*/', '', $worldMapSvg);
$route = 'statistics';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleTheme(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
@@ -1576,7 +1673,6 @@ function handleLogs(AdminAuth $auth, array $config): void
}
// Read request log
require_once __DIR__ . '/../cms/core/class/RequestLogger.php';
$requestLogger = new RequestLogger($requestLogFile);
$requestLogs = $requestLogger->getLogs(200);
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

+26 -4
View File
@@ -146,24 +146,46 @@ if (!$isAllowedIp) {
// Instantiate CMS instance
$cms = new CodePressCMS($config);
// Analytics & GeoIP settings
$analyticsSettings = $config['analytics'] ?? [];
$geoCountry = null;
if (!empty($analyticsSettings['enabled'])) {
$geoIp = new GeoIP($analyticsSettings);
$geoCountry = $geoIp->lookupCountry($clientIp);
}
// Apply IP anonymization for storage if enabled
$storedIp = !empty($analyticsSettings['anonymize_ip'])
? RequestLogger::anonymizeIp($clientIp)
: $clientIp;
// Log page view (not for media/assets)
if (!str_starts_with($path, '/-media/') && !str_starts_with($path, '/-assets/')) {
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
$loggedInUser = $_SESSION['admin_user'] ?? 'Gast';
$currentPage = $_GET['page'] ?? $cms->getEffectiveDefaultPage();
$referrer = $_SERVER['HTTP_REFERER'] ?? '';
$requestLogFile = dirname(__DIR__) . '/admin/storage/logs/requests.log';
$logger = new RequestLogger($requestLogFile);
$logger->log(
$_GET['page'] ?? $cms->getEffectiveDefaultPage(),
$clientIp,
$currentPage,
$storedIp,
$userAgent,
$_SERVER['HTTP_REFERER'] ?? '',
$referrer,
$loggedInUser,
$_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '',
$requestStatus
$requestStatus,
$geoCountry
);
// Record aggregated statistics
if (!empty($analyticsSettings['enabled'])) {
$analytics = new Analytics($analyticsSettings);
$analytics->record($currentPage, $storedIp, $userAgent, $referrer, $geoCountry, $requestStatus);
}
}
// Block request if status is not ok