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:
@@ -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)
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user