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
+8
View File
@@ -71,6 +71,11 @@ $layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
<i class="bi bi-shield-check"></i> Beveiliging
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'statistics' ? 'active' : '' ?>" href="/admin/statistics">
<i class="bi bi-bar-chart"></i> Statistieken
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'plugins' ? 'active' : '' ?>" href="/admin/plugins">
<i class="bi bi-plug"></i> Plugins
@@ -143,6 +148,9 @@ $layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
case 'security':
require __DIR__ . '/pages/security.php';
break;
case 'statistics':
require __DIR__ . '/pages/statistics.php';
break;
# Media route (removed from menu)
// Uncomment if media functionality is needed elsewhere
// require __DIR__ . '/pages/media.php';
+47
View File
@@ -1,5 +1,52 @@
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> Dashboard</h2>
<?php
$aTotals = $analyticsSummary['totals'] ?? [];
$aCountries = $analyticsSummary['countries'] ?? [];
$topCountry = null;
foreach ($aCountries as $cc => $cnt) {
if ($cc !== 'UNKNOWN') { $topCountry = $cc; break; }
}
?>
<div class="row g-4 mb-4">
<div class="col-md-4">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Weergaven (30 dagen)</h6>
<h3 class="mb-0"><?= number_format((int)($aTotals['views'] ?? 0), 0, ',', '.') ?></h3>
</div>
<i class="bi bi-eye stat-icon text-primary"></i>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Unieke bezoekers (30 dagen)</h6>
<h3 class="mb-0"><?= number_format((int)($aTotals['uniques'] ?? 0), 0, ',', '.') ?></h3>
</div>
<i class="bi bi-people stat-icon text-success"></i>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Grootste land</h6>
<h3 class="mb-0">
<?= GeoIP::getCountryFlagEmoji($topCountry) ?>
<span class="fs-5"><?= htmlspecialchars(GeoIP::getCountryName($topCountry)) ?></span>
</h3>
</div>
<a href="/admin/statistics" class="btn btn-sm btn-outline-secondary">Statistieken →</a>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="card stat-card shadow-sm">
+5
View File
@@ -68,6 +68,7 @@
<tr>
<th>Tijd</th>
<th>IP</th>
<th>Land</th>
<th>Pagina</th>
<th>Type / Gebruiker</th>
<th>Status</th>
@@ -81,6 +82,10 @@
<tr class="<?= str_starts_with($log['status'] ?? 'ok', 'blocked') ? 'table-danger' : '' ?>">
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
<td class="text-nowrap small" title="<?= htmlspecialchars(GeoIP::getCountryName($log['country'] ?: null)) ?>">
<?= GeoIP::getCountryFlagEmoji($log['country'] ?: null) ?>
<span class="text-muted"><?= htmlspecialchars($log['country'] ?: '—') ?></span>
</td>
<td><?= htmlspecialchars($log['page']) ?></td>
<td>
<span class="badge bg-<?= $log['visitor_info']['badge'] ?>">
+368
View File
@@ -0,0 +1,368 @@
<?php
$totals = $stats['totals'] ?? [];
$countries = $stats['countries'] ?? [];
$pages = $stats['pages'] ?? [];
$referrers = $stats['referrers'] ?? [];
$daily = $stats['daily_chart'] ?? [];
// Exclude the "UNKNOWN" bucket from the map scale
$mapCountries = $countries;
unset($mapCountries['UNKNOWN']);
$maxCountry = !empty($mapCountries) ? max($mapCountries) : 0;
$maxPage = !empty($pages) ? max($pages) : 0;
$maxDaily = 0;
foreach ($daily as $d) {
if (($d['views'] ?? 0) > $maxDaily) $maxDaily = $d['views'];
}
$periodLabels = [7 => 'Laatste 7 dagen', 30 => 'Laatste 30 dagen', 90 => 'Laatste 90 dagen', 0 => 'Alles'];
?>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-bar-chart"></i> Statistieken</h2>
<div class="btn-group">
<?php foreach ($periodLabels as $p => $label): ?>
<a href="/admin/statistics?period=<?= $p ?>" class="btn btn-sm <?= $period === $p ? 'btn-primary' : 'btn-outline-secondary' ?>"><?= htmlspecialchars($label) ?></a>
<?php endforeach; ?>
</div>
</div>
<!-- KPI cards -->
<div class="row g-3 mb-4">
<div class="col-md-3 col-6">
<div class="card stat-card shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Paginaweergaven</h6>
<h3 class="mb-0"><?= number_format((int)($totals['views'] ?? 0), 0, ',', '.') ?></h3>
</div>
<i class="bi bi-eye stat-icon text-primary"></i>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card stat-card shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Unieke bezoekers</h6>
<h3 class="mb-0"><?= number_format((int)($totals['uniques'] ?? 0), 0, ',', '.') ?></h3>
</div>
<i class="bi bi-people stat-icon text-success"></i>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card stat-card shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Mens / Bot</h6>
<h3 class="mb-0">
<span class="text-success"><?= number_format((int)($totals['human'] ?? 0), 0, ',', '.') ?></span>
<small class="text-muted">/</small>
<span class="text-secondary"><?= number_format((int)($totals['bot'] ?? 0), 0, ',', '.') ?></span>
</h3>
</div>
<i class="bi bi-person-check stat-icon text-info"></i>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card stat-card shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Geblokkeerd</h6>
<h3 class="mb-0 text-danger"><?= number_format((int)($totals['blocked'] ?? 0), 0, ',', '.') ?></h3>
</div>
<i class="bi bi-shield-x stat-icon text-danger"></i>
</div>
</div>
</div>
</div>
<!-- World map -->
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-globe-europe-africa"></i> Bezoekers per land</span>
<?php if ($maxCountry > 0): ?>
<small class="text-muted d-flex align-items-center gap-1">
Minder
<span style="display:inline-block;width:18px;height:12px;background:#cfe2ff;border:1px solid #dee2e6;"></span>
<span style="display:inline-block;width:18px;height:12px;background:#6ea8fe;"></span>
<span style="display:inline-block;width:18px;height:12px;background:#0d6efd;"></span>
<span style="display:inline-block;width:18px;height:12px;background:#052c65;"></span>
Meer
</small>
<?php endif; ?>
</div>
<div class="card-body">
<?php if ($worldMapSvg === ''): ?>
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle"></i> Wereldkaart niet gevonden. Genereer deze met:
<code>php cli/generate-world-map.php</code>
</div>
<?php else: ?>
<style>
<?php foreach ($mapCountries as $cc => $count):
if (!preg_match('/^[A-Z]{2}$/', $cc) || $maxCountry <= 0) continue;
$ratio = $count / $maxCountry;
if ($ratio > 0.66) $fill = '#052c65';
elseif ($ratio > 0.33) $fill = '#0d6efd';
elseif ($ratio > 0.1) $fill = '#6ea8fe';
else $fill = '#cfe2ff';
?>
#<?= $cc ?> { fill: <?= $fill ?>; }
<?php endforeach; ?>
</style>
<div class="world-map-wrapper position-relative">
<?= $worldMapSvg ?>
<div id="mapTooltip" class="position-absolute bg-dark text-white px-2 py-1 rounded small" style="display:none;pointer-events:none;z-index:10;"></div>
</div>
<script>
(function () {
var counts = <?= json_encode($mapCountries) ?>;
var wrapper = document.querySelector('.world-map-wrapper');
var tooltip = document.getElementById('mapTooltip');
if (!wrapper || !tooltip) return;
wrapper.querySelectorAll('path.country').forEach(function (p) {
p.addEventListener('mousemove', function (e) {
var code = p.getAttribute('id');
var name = p.getAttribute('data-name') || code;
var n = counts[code] || 0;
tooltip.textContent = name + ': ' + n + ' weergave' + (n === 1 ? '' : 'n');
tooltip.style.display = 'block';
var r = wrapper.getBoundingClientRect();
tooltip.style.left = (e.clientX - r.left + 12) + 'px';
tooltip.style.top = (e.clientY - r.top + 12) + 'px';
});
p.addEventListener('mouseleave', function () {
tooltip.style.display = 'none';
});
});
})();
</script>
<?php endif; ?>
</div>
<?php if ($geoMeta): ?>
<div class="card-footer text-muted small">
<?= htmlspecialchars($geoMeta['attribution'] ?? 'IP geolocation by DB-IP') ?> &middot;
Database bijgewerkt op <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
</div>
<?php endif; ?>
</div>
<div class="row g-4 mb-4">
<!-- Countries list -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header"><i class="bi bi-flag"></i> Landen</div>
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
<?php if (empty($countries)): ?>
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
<?php else: ?>
<?php $totalCountryViews = array_sum($countries); ?>
<?php foreach (array_slice($countries, 0, 25, true) as $cc => $count): ?>
<?php $pct = $totalCountryViews > 0 ? round(($count / $totalCountryViews) * 100, 1) : 0; ?>
<div class="mb-2">
<div class="d-flex justify-content-between small">
<span>
<?= GeoIP::getCountryFlagEmoji($cc === 'UNKNOWN' ? null : $cc) ?>
<?= htmlspecialchars(GeoIP::getCountryName($cc === 'UNKNOWN' ? null : $cc)) ?>
</span>
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?> (<?= $pct ?>%)</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar" style="width: <?= $pct ?>%"></div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<!-- Top pages -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header"><i class="bi bi-file-earmark-text"></i> Meest gelezen pagina's</div>
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
<?php if (empty($pages)): ?>
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
<?php else: ?>
<?php foreach (array_slice($pages, 0, 25, true) as $pageName => $count): ?>
<?php $pct = $maxPage > 0 ? round(($count / $maxPage) * 100, 1) : 0; ?>
<div class="mb-2">
<div class="d-flex justify-content-between small">
<span class="text-truncate" style="max-width: 70%;" title="<?= htmlspecialchars($pageName) ?>">
<?= htmlspecialchars($pageName) ?>
</span>
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-success" style="width: <?= $pct ?>%"></div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<!-- Daily chart -->
<div class="col-lg-8">
<div class="card shadow-sm h-100">
<div class="card-header"><i class="bi bi-graph-up"></i> Bezoekers per dag</div>
<div class="card-body">
<?php if (empty($daily) || $maxDaily === 0): ?>
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
<?php else: ?>
<?php
$chartW = 800;
$chartH = 200;
$count = count($daily);
$barW = $count > 0 ? ($chartW / $count) : 10;
?>
<svg viewBox="0 0 <?= $chartW ?> <?= $chartH + 25 ?>" width="100%" height="auto">
<?php foreach (array_values($daily) as $i => $d): ?>
<?php
$v = (int)($d['views'] ?? 0);
$h = $maxDaily > 0 ? ($v / $maxDaily) * $chartH : 0;
$x = $i * $barW;
$y = $chartH - $h;
?>
<rect x="<?= round($x + 1, 2) ?>" y="<?= round($y, 2) ?>"
width="<?= round(max($barW - 2, 1), 2) ?>" height="<?= round($h, 2) ?>"
fill="#0d6efd" rx="1">
<title><?= htmlspecialchars($d['date']) ?>: <?= $v ?> weergaven, <?= (int)($d['uniques'] ?? 0) ?> unieke bezoekers</title>
</rect>
<?php endforeach; ?>
<line x1="0" y1="<?= $chartH ?>" x2="<?= $chartW ?>" y2="<?= $chartH ?>" stroke="#dee2e6" stroke-width="1"/>
<?php $firstDay = reset($daily); $lastDay = end($daily); ?>
<text x="0" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d"><?= htmlspecialchars($firstDay['date'] ?? '') ?></text>
<text x="<?= $chartW ?>" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d" text-anchor="end"><?= htmlspecialchars($lastDay['date'] ?? '') ?></text>
</svg>
<?php endif; ?>
</div>
</div>
</div>
<!-- Referrers -->
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-header"><i class="bi bi-link-45deg"></i> Verwijzende sites</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
<?php if (empty($referrers)): ?>
<p class="text-muted mb-0">Geen verwijzingen geregistreerd.</p>
<?php else: ?>
<ul class="list-unstyled mb-0">
<?php foreach (array_slice($referrers, 0, 15, true) as $host => $count): ?>
<li class="d-flex justify-content-between border-bottom py-1 small">
<span class="text-truncate" style="max-width: 70%;"><?= htmlspecialchars($host) ?></span>
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- GeoIP & privacy settings -->
<div class="card shadow-sm mb-4">
<div class="card-header"><i class="bi bi-geo-alt"></i> GeoIP database &amp; privacy</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-8">
<?php if ($geoMeta): ?>
<p class="mb-1">
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Database aanwezig</span>
<span class="text-muted small ms-2">
<?= number_format((int)($geoMeta['ipv4_records'] ?? 0), 0, ',', '.') ?> IPv4 &middot;
<?= number_format((int)($geoMeta['ipv6_records'] ?? 0), 0, ',', '.') ?> IPv6 &middot;
bijgewerkt <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
</span>
</p>
<?php else: ?>
<p class="mb-1"><span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Nog geen lokale database</span></p>
<?php endif; ?>
</div>
<div class="col-md-4 text-md-end">
<form method="POST" action="/admin/statistics" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="update_geoip">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-cloud-download"></i> GeoIP database bijwerken
</button>
</form>
</div>
</div>
<hr>
<form method="POST" action="/admin/statistics">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="analytics_enabled" name="analytics_enabled" value="1" <?= !empty($ana['enabled']) ? 'checked' : '' ?>>
<label class="form-check-label fw-bold" for="analytics_enabled">Statistieken bijhouden</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="anonymize_ip" name="anonymize_ip" value="1" <?= !empty($ana['anonymize_ip']) ? 'checked' : '' ?>>
<label class="form-check-label fw-bold" for="anonymize_ip">IP-adressen anonimiseren</label>
<div class="form-text">Maskeert het laatste deel van het IP (82.169.10.x). Let op: de IP-blocklist wordt hierdoor minder bruikbaar.</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="geoip_provider" class="form-label fw-bold">GeoIP bron</label>
<select name="geoip_provider" id="geoip_provider" class="form-select">
<option value="local" <?= ($ana['geoip_provider'] ?? 'local') === 'local' ? 'selected' : '' ?>>Lokaal (DB-IP Lite)</option>
<option value="mmdb" <?= ($ana['geoip_provider'] ?? '') === 'mmdb' ? 'selected' : '' ?>>MaxMind database (.mmdb)</option>
<option value="api" <?= ($ana['geoip_provider'] ?? '') === 'api' ? 'selected' : '' ?>>Externe API</option>
</select>
<div class="form-text">Valt automatisch terug op de lokale database.</div>
</div>
<div class="col-md-8 mb-3">
<label for="geoip_mmdb_path" class="form-label fw-bold">Pad naar .mmdb bestand</label>
<input type="text" class="form-control font-monospace" id="geoip_mmdb_path" name="geoip_mmdb_path"
value="<?= htmlspecialchars($ana['geoip_mmdb_path'] ?? '') ?>" placeholder="/var/lib/GeoIP/GeoLite2-Country.mmdb">
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<label for="geoip_api_url" class="form-label fw-bold">API URL</label>
<input type="text" class="form-control font-monospace" id="geoip_api_url" name="geoip_api_url"
value="<?= htmlspecialchars($ana['geoip_api_url'] ?? '') ?>" placeholder="http://ip-api.com/json/{ip}?fields=countryCode">
<div class="form-text"><code>{ip}</code> wordt vervangen door het IP-adres van de bezoeker.</div>
</div>
<div class="col-md-4 mb-3">
<label for="geoip_api_key" class="form-label fw-bold">API sleutel</label>
<input type="text" class="form-control" id="geoip_api_key" name="geoip_api_key"
value="<?= htmlspecialchars($ana['geoip_api_key'] ?? '') ?>">
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="retention_days" class="form-label fw-bold">Bewaartermijn (dagen)</label>
<input type="number" class="form-control" id="retention_days" name="retention_days" min="30" max="3650"
value="<?= (int)($ana['retention_days'] ?? 400) ?>">
</div>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Instellingen opslaan</button>
</form>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted">IP geolocation by DB-IP (https://db-ip.com) &middot; CC BY 4.0</small>
<form method="POST" action="/admin/statistics" onsubmit="return confirm('Weet je zeker dat je ALLE statistieken wilt wissen?')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="reset_stats">
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Statistieken wissen</button>
</form>
</div>
</div>