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
+265
View File
@@ -0,0 +1,265 @@
<?php
/**
* Analytics - Aggregated stats recorder & statistics manager for CodePress CMS
*/
class Analytics
{
private string $statsFile;
private array $config;
public function __construct(array $analyticsConfig = [])
{
$this->config = $analyticsConfig;
$this->statsFile = dirname(__DIR__, 3) . '/admin/storage/stats.json';
$dir = dirname($this->statsFile);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
}
/**
* Record a page visit in aggregated stats.json
*
* @param string $page Page key
* @param string $ip Client IP
* @param string $userAgent User Agent string
* @param string $referrer Referrer string
* @param string $country Resolved country code (e.g. NL, BE)
* @param string $status Status string (ok, blocked:ai, blocked:ratelimit, etc.)
*/
public function record(string $page, string $ip, string $userAgent, string $referrer, ?string $country, string $status): void
{
if (empty($this->config['enabled'])) {
return;
}
$today = date('Y-m-d');
$country = ($country && strlen($country) === 2) ? strtoupper($country) : 'UNKNOWN';
$isBlocked = str_starts_with($status, 'blocked');
// Identify bot vs human
$visitorInfo = RequestLogger::detectVisitorInfo($userAgent);
$type = $visitorInfo['type'];
$isBot = in_array($type, ['ai', 'search', 'scraper', 'bot'], true);
// Anonymized unique IP salt per day
$ipSalt = date('Y-m-d') . '_codepress_salt';
$ipHash = md5($ip . $ipSalt);
// Domain/host from referrer
$refHost = 'direct';
if ($referrer !== '') {
$parsed = parse_url($referrer);
if (!empty($parsed['host'])) {
$refHost = preg_replace('/^www\./', '', strtolower($parsed['host']));
}
}
// Open stats.json with exclusive lock
$handle = @fopen($this->statsFile, 'c+');
if (!$handle) return;
if (flock($handle, LOCK_EX)) {
$fileSize = filesize($this->statsFile);
$data = [];
if ($fileSize > 0) {
$content = fread($handle, $fileSize);
$data = json_decode($content, true);
}
if (!is_array($data)) {
$data = [
'totals' => ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0],
'countries' => [],
'pages' => [],
'referrers' => [],
'days' => [],
'uniques' => []
];
}
// Totals
$data['totals']['views'] = ($data['totals']['views'] ?? 0) + 1;
if ($isBlocked) {
$data['totals']['blocked'] = ($data['totals']['blocked'] ?? 0) + 1;
} elseif ($isBot) {
$data['totals']['bot'] = ($data['totals']['bot'] ?? 0) + 1;
} else {
$data['totals']['human'] = ($data['totals']['human'] ?? 0) + 1;
}
// Countries
$data['countries'][$country] = ($data['countries'][$country] ?? 0) + 1;
// Pages
$data['pages'][$page] = ($data['pages'][$page] ?? 0) + 1;
// Referrers
if ($refHost !== 'direct') {
$data['referrers'][$refHost] = ($data['referrers'][$refHost] ?? 0) + 1;
}
// Day stats
if (!isset($data['days'][$today])) {
$data['days'][$today] = ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0, 'countries' => [], 'pages' => []];
}
$data['days'][$today]['views']++;
if ($isBlocked) {
$data['days'][$today]['blocked']++;
} elseif ($isBot) {
$data['days'][$today]['bot']++;
} else {
$data['days'][$today]['human']++;
}
$data['days'][$today]['countries'][$country] = ($data['days'][$today]['countries'][$country] ?? 0) + 1;
$data['days'][$today]['pages'][$page] = ($data['days'][$today]['pages'][$page] ?? 0) + 1;
// Uniques per day
if (!isset($data['uniques'][$today])) {
$data['uniques'][$today] = [];
}
if (!in_array($ipHash, $data['uniques'][$today], true)) {
$data['uniques'][$today][] = $ipHash;
}
// Cleanup retention (keep max retention_days)
$retentionDays = max(30, (int)($this->config['retention_days'] ?? 400));
$cutoffDate = date('Y-m-d', strtotime("-{$retentionDays} days"));
foreach (array_keys($data['days']) as $d) {
if ($d < $cutoffDate) {
unset($data['days'][$d]);
unset($data['uniques'][$d]);
}
}
// Rewrite stats file
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
fflush($handle);
flock($handle, LOCK_UN);
}
fclose($handle);
}
/**
* Get aggregated statistics data for a specific period
*
* @param int $days Number of days (e.g. 7, 30, 90, 0 for all)
* @return array Aggregated stats
*/
public function getStats(int $days = 30): array
{
if (!file_exists($this->statsFile)) {
return [
'totals' => ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0, 'uniques' => 0],
'countries' => [],
'pages' => [],
'referrers' => [],
'daily_chart' => [],
];
}
$raw = json_decode(file_get_contents($this->statsFile), true);
if (!is_array($raw)) $raw = [];
if ($days === 0) {
// All time
$countries = $raw['countries'] ?? [];
$pages = $raw['pages'] ?? [];
$referrers = $raw['referrers'] ?? [];
$totals = $raw['totals'] ?? ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0];
$totalUniques = 0;
foreach ($raw['uniques'] ?? [] as $uList) {
$totalUniques += count($uList);
}
$totals['uniques'] = $totalUniques;
$dailyChart = [];
foreach ($raw['days'] ?? [] as $date => $dData) {
$dailyChart[$date] = [
'date' => $date,
'views' => $dData['views'] ?? 0,
'human' => $dData['human'] ?? 0,
'uniques' => count($raw['uniques'][$date] ?? [])
];
}
ksort($dailyChart);
arsort($countries);
arsort($pages);
arsort($referrers);
return [
'totals' => $totals,
'countries' => $countries,
'pages' => $pages,
'referrers' => $referrers,
'daily_chart' => array_values($dailyChart)
];
}
// Filtered by last $days days
$cutoff = date('Y-m-d', strtotime("-{$days} days"));
$filteredCountries = [];
$filteredPages = [];
$filteredTotals = ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0, 'uniques' => 0];
$dailyChart = [];
foreach ($raw['days'] ?? [] as $date => $dData) {
if ($date >= $cutoff) {
$v = $dData['views'] ?? 0;
$h = $dData['human'] ?? 0;
$b = $dData['bot'] ?? 0;
$bl = $dData['blocked'] ?? 0;
$u = count($raw['uniques'][$date] ?? []);
$filteredTotals['views'] += $v;
$filteredTotals['human'] += $h;
$filteredTotals['bot'] += $b;
$filteredTotals['blocked'] += $bl;
$filteredTotals['uniques'] += $u;
foreach ($dData['countries'] ?? [] as $c => $cnt) {
$filteredCountries[$c] = ($filteredCountries[$c] ?? 0) + $cnt;
}
foreach ($dData['pages'] ?? [] as $p => $cnt) {
$filteredPages[$p] = ($filteredPages[$p] ?? 0) + $cnt;
}
$dailyChart[$date] = [
'date' => $date,
'views' => $v,
'human' => $h,
'uniques' => $u
];
}
}
// Fill missing dates in range for smooth chart
for ($i = $days - 1; $i >= 0; $i--) {
$dStr = date('Y-m-d', strtotime("-{$i} days"));
if (!isset($dailyChart[$dStr])) {
$dailyChart[$dStr] = ['date' => $dStr, 'views' => 0, 'human' => 0, 'uniques' => 0];
}
}
ksort($dailyChart);
arsort($filteredCountries);
arsort($filteredPages);
$referrers = $raw['referrers'] ?? [];
arsort($referrers);
return [
'totals' => $filteredTotals,
'countries' => $filteredCountries,
'pages' => $filteredPages,
'referrers' => $referrers,
'daily_chart' => array_values($dailyChart)
];
}
}