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
+2
View File
@@ -17,6 +17,8 @@ Thumbs.db
.cache/
.sass-cache/
admin/storage/cache/
admin/storage/geoip/
admin/storage/stats.json
# Temporary files
*.tmp
+32 -1
View File
@@ -2,6 +2,34 @@
## ✅ Voltooid (recent)
### Statistieken & GeoIP (v1.9.0)
- [x] `GeoIP` class met providerketen: lokaal (DB-IP Lite) → MaxMind `.mmdb` → externe API, met automatische fallback
- [x] Eigen pure-PHP MMDB-lezer (`MMDBReader`), geen Composer-afhankelijkheid nodig
- [x] `cli/geoip-update.php` — downloadt DB-IP Lite en bouwt compacte binaire index (354k IPv4 + 342k IPv6 records)
- [x] Binary search lookup via `fseek` voor IPv4 (10 bytes/record) en IPv6 (34 bytes/record)
- [x] Placeholder-landcodes (`ZZ`/`XX`) tellen als onbekend
- [x] `cli/generate-world-map.php` — genereert SVG-wereldkaart uit Natural Earth TopoJSON (publiek domein) met ISO alpha-2 id's
- [x] `Analytics` class met aggregatie in `admin/storage/stats.json` (LOCK_EX), overleeft het wissen van logs
- [x] Admin pagina `/admin/statistics`: KPI's, choropleth wereldkaart met tooltips, landenlijst met vlaggen, top pagina's, dagelijkse grafiek, referrers
- [x] Periodefilter 7 / 30 / 90 dagen / alles
- [x] GeoIP- en privacy-instellingen in admin (provider, `.mmdb` pad, API URL/sleutel, bewaartermijn)
- [x] Knop "GeoIP database bijwerken" en "Statistieken wissen" in admin
- [x] IP-anonimisering als schakelaar (`RequestLogger::anonymizeIp()`), standaard uit
- [x] Landkolom met vlag in requests log + KPI-kaarten op dashboard
- [x] `requests.log` uitgebreid met landcode (9e veld), parser accepteert 7/8/9 velden
### Beveiliging (v1.8.0)
- [x] `BotGuard` engine: AI-crawlers, zoekmachines, scrapers en lege user-agents herkennen en blokkeren
- [x] Admin pagina `/admin/security` met schakelaars, rate limiting en IP block/allowlist
- [x] Rate limiting per IP (HTTP 429 met `Retry-After`)
- [x] Dynamische `/robots.txt` en `noai`/`noimageai` meta-tags
- [x] Statuskolom met badges in requests log
- [x] Echte bezoeker-IP achter HAProxy/PFSense (`RequestLogger::getClientIp()`, 2-pass publiek IP filter)
- [x] HAProxy/PFSense handleiding (`docs/haproxy-bot-blocking.md`)
- [x] Eén-klik systeemupdate via `/admin/update` met controle op Git-schrijfrechten
- [x] `config.json` en `admin/config/admin.json` uit Git, automatisch aangemaakt indien afwezig
- [x] **ARIAComponents.php parse error** — opgelost op regels 67, 137 en 262 (`'UTF-8)``'UTF-8')`)
### Media & Editor
- [x] Media browser modal met upload, thumbnail grid, en size-prompt
- [x] Media knop in editor toolbar voor alle modes (md/html/php)
@@ -87,4 +115,7 @@
- [ ] **Keyboard shortcuts** — Ctrl+S om op te slaan in editor, Ctrl+N voor nieuw bestand
- [ ] **Dark mode** — Admin panel dark mode toggle
- [ ] **Responsive admin** — Admin sidebar inklapbaar op mobiel (nu is het gestacked)
- [x] **ARIAComponents.php parse error** — Bestaande PHP parse error op regel 67 opgelost (`'UTF-8)` -> `'UTF-8')`)
- [ ] **stats.json bij hoog verkeer** — Nu één schrijfactie met LOCK_EX per request. Bij veel verkeer eventueel opsplitsen naar dagbestanden of APCu
- [ ] **Steden op de kaart** — Nu alleen landniveau; met een City-database ook stippen per stad plotten
- [ ] **Statistieken exporteren** — CSV/JSON export van bezoekersstatistieken
- [ ] **Automatische GeoIP-update** — Maandelijkse cron/schedule i.p.v. handmatig op de knop drukken
+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>
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env php
<?php
/**
* Natural Earth World Map SVG generator for CodePress CMS
* Converts Natural Earth 110m TopoJSON to SVG with ISO alpha-2 country IDs
*/
function generateWorldMapSvg(): bool
{
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
$imgDir = dirname($outPath);
if (!is_dir($imgDir)) {
@mkdir($imgDir, 0755, true);
}
$topoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
$cacheFile = '/tmp/world-atlas-110m.json';
if (!file_exists($cacheFile) || filesize($cacheFile) < 1000) {
$ctx = stream_context_create(['http' => ['timeout' => 15, 'user_agent' => 'CodePressCMS/1.9.0']]);
$data = @file_get_contents($topoUrl, false, $ctx);
if ($data) {
file_put_contents($cacheFile, $data);
}
}
if (!file_exists($cacheFile)) {
return false;
}
$topo = json_decode(file_get_contents($cacheFile), true);
if (!$topo || empty($topo['objects']['countries']['geometries'])) {
return false;
}
// Mapping ISO 3166-1 numeric code -> ISO 3166-1 alpha-2 code
$numericToAlpha2 = [
'4' => 'AF', '8' => 'AL', '12' => 'DZ', '20' => 'AD', '24' => 'AO', '28' => 'AG', '32' => 'AR', '51' => 'AM',
'36' => 'AU', '40' => 'AT', '31' => 'AZ', '44' => 'BS', '48' => 'BH', '50' => 'BD', '52' => 'BB', '112' => 'BY',
'56' => 'BE', '84' => 'BZ', '204' => 'BJ', '64' => 'BT', '68' => 'BO', '70' => 'BA', '72' => 'BW', '76' => 'BR',
'96' => 'BN', '100' => 'BG', '854' => 'BF', '108' => 'BI', '116' => 'KH', '120' => 'CM', '124' => 'CA', '132' => 'CV',
'140' => 'CF', '148' => 'TD', '152' => 'CL', '156' => 'CN', '170' => 'CO', '174' => 'KM', '178' => 'CG', '180' => 'CD',
'188' => 'CR', '384' => 'CI', '191' => 'HR', '192' => 'CU', '196' => 'CY', '203' => 'CZ', '208' => 'DK', '262' => 'DJ',
'212' => 'DM', '214' => 'DO', '218' => 'EC', '818' => 'EG', '222' => 'SV', '226' => 'GQ', '232' => 'ER', '233' => 'EE',
'231' => 'ET', '242' => 'FJ', '246' => 'FI', '250' => 'FR', '266' => 'GA', '270' => 'GM', '268' => 'GE', '276' => 'DE',
'288' => 'GH', '300' => 'GR', '308' => 'GD', '320' => 'GT', '324' => 'GN', '624' => 'GW', '328' => 'GY', '332' => 'HT',
'340' => 'HN', '348' => 'HU', '352' => 'IS', '356' => 'IN', '360' => 'ID', '364' => 'IR', '368' => 'IQ', '372' => 'IE',
'376' => 'IL', '380' => 'IT', '388' => 'JM', '392' => 'JP', '400' => 'JO', '398' => 'KZ', '404' => 'KE', '296' => 'KI',
'408' => 'KP', '410' => 'KR', '414' => 'KW', '417' => 'KG', '418' => 'LA', '428' => 'LV', '422' => 'LB', '426' => 'LS',
'430' => 'LR', '434' => 'LY', '438' => 'LI', '440' => 'LT', '442' => 'LU', '807' => 'MK', '450' => 'MG', '454' => 'MW',
'458' => 'MY', '462' => 'MV', '466' => 'ML', '470' => 'MT', '584' => 'MH', '478' => 'MR', '480' => 'MU', '484' => 'MX',
'583' => 'FM', '498' => 'MD', '492' => 'MC', '496' => 'MN', '499' => 'ME', '504' => 'MA', '508' => 'MZ', '104' => 'MM',
'516' => 'NA', '520' => 'NR', '524' => 'NP', '528' => 'NL', '554' => 'NZ', '558' => 'NI', '562' => 'NE', '566' => 'NG',
'578' => 'NO', '512' => 'OM', '586' => 'PK', '585' => 'PW', '591' => 'PA', '598' => 'PG', '600' => 'PY', '604' => 'PE',
'608' => 'PH', '616' => 'PL', '620' => 'PT', '634' => 'QA', '642' => 'RO', '643' => 'RU', '646' => 'RW', '659' => 'KN',
'662' => 'LC', '670' => 'VC', '882' => 'WS', '674' => 'SM', '678' => 'ST', '682' => 'SA', '686' => 'SN', '688' => 'RS',
'690' => 'SC', '694' => 'SL', '702' => 'SG', '703' => 'SK', '705' => 'SI', '90' => 'SB', '706' => 'SO', '710' => 'ZA',
'728' => 'SS', '724' => 'ES', '144' => 'LK', '729' => 'SD', '740' => 'SR', '748' => 'SZ', '752' => 'SE', '756' => 'CH',
'760' => 'SY', '158' => 'TW', '762' => 'TJ', '834' => 'TZ', '764' => 'TH', '626' => 'TL', '768' => 'TG', '776' => 'TO',
'780' => 'TT', '788' => 'TN', '792' => 'TR', '795' => 'TM', '798' => 'TV', '800' => 'UG', '804' => 'UA', '784' => 'AE',
'826' => 'GB', '840' => 'US', '858' => 'UY', '860' => 'UZ', '548' => 'VU', '862' => 'VE', '704' => 'VN', '887' => 'YE',
'894' => 'ZM', '716' => 'ZW', '-99' => 'XK'
];
// Decode TopoJSON arcs
$scale = $topo['transform']['scale'];
$translate = $topo['transform']['translate'];
$rawArcs = $topo['arcs'];
$decodedArcs = [];
foreach ($rawArcs as $arcIndex => $arc) {
$x = 0;
$y = 0;
$points = [];
foreach ($arc as $position) {
$x += $position[0];
$y += $position[1];
// Equirectangular projection mapping to 1000x500 canvas
$lon = $x * $scale[0] + $translate[0];
$lat = $y * $scale[1] + $translate[1];
$svgX = round(($lon + 180) * (1000 / 360), 2);
$svgY = round((90 - $lat) * (500 / 180), 2);
$points[] = [$svgX, $svgY];
}
$decodedArcs[$arcIndex] = $points;
}
$svgPaths = [];
foreach ($topo['objects']['countries']['geometries'] as $geo) {
$numericId = (string)($geo['id'] ?? '');
$countryName = $geo['properties']['name'] ?? 'Unknown';
$alpha2 = $numericToAlpha2[$numericId] ?? null;
if (!$alpha2) continue;
$pathD = '';
$type = $geo['type'];
$arcsList = ($type === 'Polygon') ? [$geo['arcs']] : (($type === 'MultiPolygon') ? $geo['arcs'] : []);
foreach ($arcsList as $polygon) {
foreach ($polygon as $ringIndex => $ring) {
$ringPoints = [];
foreach ($ring as $arcIdx) {
$reversed = $arcIdx < 0;
$actualIdx = $reversed ? ~$arcIdx : $arcIdx;
$arcPoints = $decodedArcs[$actualIdx] ?? [];
if ($reversed) {
$arcPoints = array_reverse($arcPoints);
}
if (!empty($ringPoints)) {
array_shift($arcPoints); // Skip duplicate start point
}
$ringPoints = array_merge($ringPoints, $arcPoints);
}
if (empty($ringPoints)) continue;
$pathD .= 'M' . $ringPoints[0][0] . ',' . $ringPoints[0][1] . ' ';
for ($p = 1; $p < count($ringPoints); $p++) {
$pathD .= 'L' . $ringPoints[$p][0] . ',' . $ringPoints[$p][1] . ' ';
}
$pathD .= 'Z ';
}
}
if (trim($pathD) !== '') {
$svgPaths[] = sprintf(
' <path id="%s" data-name="%s" class="country" d="%s"><title>%s</title></path>',
htmlspecialchars($alpha2),
htmlspecialchars($countryName),
trim($pathD),
htmlspecialchars($countryName)
);
}
}
$svgContent = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$svgContent .= '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500" width="100%" height="auto" class="codepress-world-map">' . "\n";
$svgContent .= ' <style>' . "\n";
$svgContent .= ' .country { fill: #e9ecef; stroke: #ffffff; stroke-width: 0.6; stroke-linejoin: round; transition: fill 0.2s ease; cursor: pointer; }' . "\n";
$svgContent .= ' .country:hover { fill: #0d6efd !important; opacity: 0.9; }' . "\n";
$svgContent .= ' </style>' . "\n";
$svgContent .= ' <rect width="1000" height="500" fill="#f8f9fa"/>' . "\n";
$svgContent .= implode("\n", $svgPaths) . "\n";
$svgContent .= '</svg>';
file_put_contents($outPath, $svgContent);
return true;
}
if (php_sapi_name() === 'cli') {
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
if (generateWorldMapSvg()) {
echo "Wereldkaart SVG succesvol aangemaakt in public/assets/img/world-map.svg!\n";
} else {
echo "Fout bij genereren van wereldkaart SVG.\n";
}
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* DB-IP Lite database downloader & binary converter for CodePress CMS
*/
if (php_sapi_name() !== 'cli' && (!isset($_SESSION['admin_user']))) {
// Can also be included from admin handler
}
function updateGeoIPDatabase(): array
{
$baseDir = dirname(__DIR__) . '/admin/storage/geoip';
if (!is_dir($baseDir)) {
@mkdir($baseDir, 0755, true);
}
$currentDate = new DateTime('first day of this month');
$urls = [];
for ($i = 0; $i < 3; $i++) {
$ym = $currentDate->format('Y-m');
$urls[] = "https://download.db-ip.com/free/dbip-country-lite-{$ym}.csv.gz";
$currentDate->modify('-1 month');
}
$downloadUrl = null;
$gzContent = null;
foreach ($urls as $url) {
$ctx = stream_context_create(['http' => ['timeout' => 15, 'user_agent' => 'CodePressCMS/1.9.0']]);
$data = @file_get_contents($url, false, $ctx);
if ($data !== false && strlen($data) > 1000) {
$downloadUrl = $url;
$gzContent = $data;
break;
}
}
if (!$gzContent) {
return ['success' => false, 'message' => 'Kon DB-IP Lite database niet downloaden vanaf DB-IP.com.'];
}
$csvData = @gzdecode($gzContent);
if (!$csvData) {
return ['success' => false, 'message' => 'Kon gecomprimeerde DB-IP database niet uitpakken.'];
}
$ipv4BinPath = $baseDir . '/ipv4.bin';
$ipv6BinPath = $baseDir . '/ipv6.bin';
$v4Handle = fopen($ipv4BinPath, 'wb');
$v6Handle = fopen($ipv6BinPath, 'wb');
$lines = explode("\n", $csvData);
$v4Count = 0;
$v6Count = 0;
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
$parts = str_getcsv($line);
if (count($parts) < 3) continue;
$startIp = trim($parts[0]);
$endIp = trim($parts[1]);
$country = strtoupper(trim($parts[2]));
if (strlen($country) !== 2) continue;
if (filter_var($startIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$startLong = ip2long($startIp);
$endLong = ip2long($endIp);
if ($startLong !== false && $endLong !== false) {
// Pack 4-byte uint32 start, 4-byte uint32 end, 2-byte country code
$record = pack('NNa2', $startLong, $endLong, $country);
fwrite($v4Handle, $record);
$v4Count++;
}
} elseif (filter_var($startIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$startBin = inet_pton($startIp);
$endBin = inet_pton($endIp);
if ($startBin !== false && $endBin !== false) {
// Pack 16-byte start, 16-byte end, 2-byte country code
$record = $startBin . $endBin . $country;
fwrite($v6Handle, $record);
$v6Count++;
}
}
}
fclose($v4Handle);
fclose($v6Handle);
$meta = [
'source' => 'DB-IP Lite',
'attribution' => 'IP geolocation by DB-IP (https://dbip.com)',
'updated' => date('Y-m-d H:i:s'),
'url' => $downloadUrl,
'ipv4_records' => $v4Count,
'ipv6_records' => $v6Count,
'ipv4_size' => filesize($ipv4BinPath),
'ipv6_size' => filesize($ipv6BinPath),
];
file_put_contents($baseDir . '/meta.json', json_encode($meta, JSON_PRETTY_PRINT));
return [
'success' => true,
'message' => "GeoIP database succesvol bijgewerkt! ({$v4Count} IPv4, {$v6Count} IPv6 records)",
'meta' => $meta
];
}
if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
echo "DB-IP Lite database bijwerken...\n";
$res = updateGeoIPDatabase();
echo $res['message'] . "\n";
}
+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)
];
}
}
+425
View File
@@ -0,0 +1,425 @@
<?php
/**
* GeoIP - Country lookup provider chain (Local binary, MMDB, and API)
*/
class GeoIP
{
private array $config;
private ?FileCache $cache = null;
public function __construct(array $analyticsConfig = [])
{
$this->config = $analyticsConfig;
$cacheDir = dirname(__DIR__, 3) . '/admin/storage/cache';
$this->cache = new FileCache($cacheDir);
}
/**
* Resolve country code (2-letter ISO alpha-2, upper-case) from an IP address
*
* @param string $ip IPv4 or IPv6 address
* @return string|null Country code or null if unresolved/private
*/
public function lookupCountry(string $ip): ?string
{
$ip = trim($ip);
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return null;
}
$provider = $this->config['geoip_provider'] ?? 'local';
switch ($provider) {
case 'mmdb':
$mmdbPath = $this->config['geoip_mmdb_path'] ?? '';
if ($mmdbPath !== '' && file_exists($mmdbPath)) {
$code = $this->lookupMMDB($ip, $mmdbPath);
if ($code !== null) return self::normalizeCode($code);
}
// Fallback to local
return self::normalizeCode($this->lookupLocal($ip));
case 'api':
$code = $this->lookupApi($ip);
if ($code !== null) return self::normalizeCode($code);
// Fallback to local
return self::normalizeCode($this->lookupLocal($ip));
case 'local':
default:
return self::normalizeCode($this->lookupLocal($ip));
}
}
/**
* Normalize a country code; placeholder codes (ZZ/XX) count as unknown
*/
private static function normalizeCode(?string $code): ?string
{
if ($code === null) return null;
$code = strtoupper(trim($code));
if (!preg_match('/^[A-Z]{2}$/', $code)) return null;
if (in_array($code, ['ZZ', 'XX'], true)) return null;
return $code;
}
/**
* Binary search lookup in local DB-IP IPv4/IPv6 binary files
*/
public function lookupLocal(string $ip): ?string
{
$baseDir = dirname(__DIR__, 3) . '/admin/storage/geoip';
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$binPath = $baseDir . '/ipv4.bin';
if (!file_exists($binPath)) return null;
$ipLong = sprintf('%u', ip2long($ip));
$recordSize = 10; // 4 bytes start_ip, 4 bytes end_ip, 2 bytes country
$fileSize = filesize($binPath);
if ($fileSize < $recordSize) return null;
$totalRecords = (int)($fileSize / $recordSize);
$low = 0;
$high = $totalRecords - 1;
$handle = @fopen($binPath, 'rb');
if (!$handle) return null;
while ($low <= $high) {
$mid = (int)(($low + $high) / 2);
fseek($handle, $mid * $recordSize);
$data = fread($handle, $recordSize);
if (strlen($data) < $recordSize) break;
$unpacked = unpack('Nstart/Nend/a2country', $data);
$start = sprintf('%u', $unpacked['start']);
$end = sprintf('%u', $unpacked['end']);
if ($ipLong >= $start && $ipLong <= $end) {
fclose($handle);
return strtoupper($unpacked['country']);
}
if ($ipLong < $start) {
$high = $mid - 1;
} else {
$low = $mid + 1;
}
}
fclose($handle);
return null;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$binPath = $baseDir . '/ipv6.bin';
if (!file_exists($binPath)) return null;
$ipBin = inet_pton($ip);
if ($ipBin === false || strlen($ipBin) !== 16) return null;
$recordSize = 34; // 16 bytes start, 16 bytes end, 2 bytes country
$fileSize = filesize($binPath);
if ($fileSize < $recordSize) return null;
$totalRecords = (int)($fileSize / $recordSize);
$low = 0;
$high = $totalRecords - 1;
$handle = @fopen($binPath, 'rb');
if (!$handle) return null;
while ($low <= $high) {
$mid = (int)(($low + $high) / 2);
fseek($handle, $mid * $recordSize);
$data = fread($handle, $recordSize);
if (strlen($data) < $recordSize) break;
$startBin = substr($data, 0, 16);
$endBin = substr($data, 16, 16);
$country = substr($data, 32, 2);
if (strcmp($ipBin, $startBin) >= 0 && strcmp($ipBin, $endBin) <= 0) {
fclose($handle);
return strtoupper($country);
}
if (strcmp($ipBin, $startBin) < 0) {
$high = $mid - 1;
} else {
$low = $mid + 1;
}
}
fclose($handle);
return null;
}
return null;
}
/**
* External API lookup with caching
*/
private function lookupApi(string $ip): ?string
{
$cacheKey = 'geoip_api_' . md5($ip);
if ($this->cache->has($cacheKey)) {
return $this->cache->get($cacheKey);
}
$apiUrl = $this->config['geoip_api_url'] ?? 'http://ip-api.com/json/{ip}?fields=countryCode';
$apiUrl = str_replace('{ip}', urlencode($ip), $apiUrl);
if (!empty($this->config['geoip_api_key'])) {
$apiUrl .= (str_contains($apiUrl, '?') ? '&' : '?') . 'key=' . urlencode($this->config['geoip_api_key']);
}
$ctx = stream_context_create(['http' => ['timeout' => 3, 'user_agent' => 'CodePressCMS/1.9.0']]);
$response = @file_get_contents($apiUrl, false, $ctx);
if ($response) {
$json = json_decode($response, true);
$code = $json['countryCode'] ?? $json['country_code'] ?? null;
if ($code && strlen($code) === 2) {
$code = strtoupper($code);
$this->cache->set($cacheKey, $code, 86400 * 7); // Cache for 24h * 7
return $code;
}
}
return null;
}
/**
* Pure-PHP MaxMind MMDB reader
*/
private function lookupMMDB(string $ip, string $filePath): ?string
{
try {
$reader = new MMDBReader($filePath);
$record = $reader->get($ip);
return $record['country']['iso_code'] ?? $record['registered_country']['iso_code'] ?? null;
} catch (\Throwable $e) {
return null;
}
}
/**
* Convert 2-letter ISO country code to regional indicator flag emoji
*/
public static function getCountryFlagEmoji(?string $code): string
{
if (!$code || strlen($code) !== 2) {
return '🌐';
}
$code = strtoupper($code);
$first = ord($code[0]) - 65 + 0x1F1E6;
$second = ord($code[1]) - 65 + 0x1F1E6;
return mb_chr($first, 'UTF-8') . mb_chr($second, 'UTF-8');
}
/**
* Get country name in Dutch or English
*/
public static function getCountryName(?string $code, string $lang = 'nl'): string
{
if (!$code) return 'Lokaal / Onbekend';
$code = strtoupper($code);
$names = [
'NL' => ['nl' => 'Nederland', 'en' => 'Netherlands'],
'BE' => ['nl' => 'België', 'en' => 'Belgium'],
'DE' => ['nl' => 'Duitsland', 'en' => 'Germany'],
'FR' => ['nl' => 'Frankrijk', 'en' => 'France'],
'GB' => ['nl' => 'Verenigd Koninkrijk', 'en' => 'United Kingdom'],
'US' => ['nl' => 'Verenigde Staten', 'en' => 'United States'],
'CA' => ['nl' => 'Canada', 'en' => 'Canada'],
'ES' => ['nl' => 'Spanje', 'en' => 'Spain'],
'IT' => ['nl' => 'Italië', 'en' => 'Italy'],
'PL' => ['nl' => 'Polen', 'en' => 'Poland'],
'AT' => ['nl' => 'Oostenrijk', 'en' => 'Austria'],
'CH' => ['nl' => 'Zwitserland', 'en' => 'Switzerland'],
'SE' => ['nl' => 'Zweden', 'en' => 'Sweden'],
'NO' => ['nl' => 'Noorwegen', 'en' => 'Norway'],
'DK' => ['nl' => 'Denemarken', 'en' => 'Denmark'],
'FI' => ['nl' => 'Finland', 'en' => 'Finland'],
'IE' => ['nl' => 'Ierland', 'en' => 'Ireland'],
'PT' => ['nl' => 'Portugal', 'en' => 'Portugal'],
'GR' => ['nl' => 'Griekenland', 'en' => 'Greece'],
'CZ' => ['nl' => 'Tsjechië', 'en' => 'Czechia'],
'CN' => ['nl' => 'China', 'en' => 'China'],
'JP' => ['nl' => 'Japan', 'en' => 'Japan'],
'IN' => ['nl' => 'India', 'en' => 'India'],
'BR' => ['nl' => 'Brazilië', 'en' => 'Brazil'],
'AU' => ['nl' => 'Australië', 'en' => 'Australia'],
'RU' => ['nl' => 'Rusland', 'en' => 'Russia'],
'ZA' => ['nl' => 'Zuid-Afrika', 'en' => 'South Africa'],
'TR' => ['nl' => 'Turkije', 'en' => 'Turkey'],
'UA' => ['nl' => 'Oekraïne', 'en' => 'Ukraine'],
'MX' => ['nl' => 'Mexico', 'en' => 'Mexico'],
'ID' => ['nl' => 'Indonesië', 'en' => 'Indonesia'],
'SG' => ['nl' => 'Singapore', 'en' => 'Singapore'],
'KR' => ['nl' => 'Zuid-Korea', 'en' => 'South Korea'],
'AR' => ['nl' => 'Argentinië', 'en' => 'Argentina'],
];
if (isset($names[$code][$lang])) {
return $names[$code][$lang];
}
return $code;
}
}
/**
* Built-in pure-PHP MaxMind DB Reader
*/
class MMDBReader
{
private string $file;
private $handle;
private array $meta;
public function __construct(string $file)
{
if (!file_exists($file)) {
throw new \InvalidArgumentException("MMDB file does not exist: {$file}");
}
$this->file = $file;
$this->handle = fopen($file, 'rb');
$this->loadMetadata();
}
public function __destruct()
{
if ($this->handle) {
fclose($this->handle);
}
}
private function loadMetadata(): void
{
$stat = fstat($this->handle);
$size = $stat['size'];
$marker = "\xab\xcd\xefMaxMind.com\x01";
fseek($this->handle, max(0, $size - 128000));
$buffer = fread($this->handle, 128000);
$pos = strrpos($buffer, $marker);
if ($pos === false) {
throw new \RuntimeException("Invalid MMDB file format: {$this->file}");
}
$metaOffset = $size - 128000 + $pos + strlen($marker);
fseek($this->handle, $metaOffset);
$this->meta = $this->decodeData($metaOffset)[0];
}
public function get(string $ip): ?array
{
$ipBin = inet_pton($ip);
if ($ipBin === false) return null;
$isV4 = strlen($ipBin) === 4;
$nodeCount = $this->meta['node_count'] ?? 0;
$recordSize = $this->meta['record_size'] ?? 28;
$ipVersion = $this->meta['ip_version'] ?? 6;
// Start node search
$node = 0;
$bitLength = $isV4 ? 32 : 128;
// If IPv4 in IPv6 tree
if ($isV4 && $ipVersion === 6) {
$node = $this->meta['ipv4_instance_count'] ?? 0;
}
for ($i = 0; $i < $bitLength; $i++) {
if ($node >= $nodeCount) break;
$byteIndex = (int)($i / 8);
$bit = (ord($ipBin[$byteIndex]) >> (7 - ($i % 8))) & 1;
$node = $this->readNode($node, $bit, $recordSize);
}
if ($node >= $nodeCount) {
$dataOffset = $node - $nodeCount + ($nodeCount * ($recordSize * 2 / 8)) + 16;
return $this->decodeData($dataOffset)[0];
}
return null;
}
private function readNode(int $node, int $bit, int $recordSize): int
{
$bytesPerRecord = $recordSize / 4; // 28-bit -> 3.5 bytes per record
$nodeOffset = (int)($node * $recordSize * 2 / 8);
fseek($this->handle, $nodeOffset);
$bytes = fread($this->handle, 8);
if ($recordSize === 28) {
$left = (ord($bytes[0]) << 16) | (ord($bytes[1]) << 8) | ord($bytes[2]) | ((ord($bytes[3]) & 0xf0) << 20);
$right = (ord($bytes[4]) << 16) | (ord($bytes[5]) << 8) | ord($bytes[6]) | ((ord($bytes[3]) & 0x0f) << 24);
return $bit === 0 ? $left : $right;
}
return 0;
}
private function decodeData(int $offset): array
{
fseek($this->handle, $offset);
$ctrl = ord(fread($this->handle, 1));
$type = $ctrl >> 5;
$size = $ctrl & 0x1f;
if ($type === 0) {
$type = ord(fread($this->handle, 1)) + 7;
}
if ($size >= 29) {
$bytesToRead = $size - 28;
$extSize = 0;
for ($i = 0; $i < $bytesToRead; $i++) {
$extSize = ($extSize << 8) | ord(fread($this->handle, 1));
}
$size = $extSize + 29;
if ($bytesToRead === 1) $size += 0;
elseif ($bytesToRead === 2) $size += 248;
elseif ($bytesToRead === 3) $size += 65816;
}
switch ($type) {
case 1: // Pointer
return [$this->decodeData(ftell($this->handle) + $size)[0], ftell($this->handle)];
case 2: // String
return [fread($this->handle, $size), ftell($this->handle)];
case 3: // Double
return [0.0, ftell($this->handle)];
case 5: // Uint32/64
$val = 0;
for ($i = 0; $i < $size; $i++) {
$val = ($val << 8) | ord(fread($this->handle, 1));
}
return [$val, ftell($this->handle)];
case 7: // Map
$map = [];
for ($i = 0; $i < $size; $i++) {
[$key, ] = $this->decodeData(ftell($this->handle));
[$val, ] = $this->decodeData(ftell($this->handle));
$map[$key] = $val;
}
return [$map, ftell($this->handle)];
case 11: // Bool
return [$size === 1, ftell($this->handle)];
default:
return [null, ftell($this->handle)];
}
}
}
+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,
];
}
}
+37
View File
@@ -45,6 +45,15 @@ if (!file_exists($configJsonPath)) {
'custom_blocked_agents' => [],
'blocked_ips' => [],
'allowed_ips' => []
],
'analytics' => [
'enabled' => true,
'anonymize_ip' => false,
'geoip_provider' => 'local',
'geoip_mmdb_path' => '',
'geoip_api_url' => '',
'geoip_api_key' => '',
'retention_days' => 400
]
];
@file_put_contents($configJsonPath, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
@@ -56,6 +65,34 @@ if (file_exists($configJsonPath)) {
$config = json_decode($jsonContent, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($config)) {
// Merge defaults for sections that may be missing in existing installs
$sectionDefaults = [
'security' => [
'block_ai_bots' => true,
'block_scrapers' => true,
'block_search_engines' => false,
'block_empty_user_agent' => true,
'rate_limit_enabled' => true,
'rate_limit_max' => 60,
'rate_limit_window' => 60,
'custom_blocked_agents' => [],
'blocked_ips' => [],
'allowed_ips' => [],
],
'analytics' => [
'enabled' => true,
'anonymize_ip' => false,
'geoip_provider' => 'local',
'geoip_mmdb_path' => '',
'geoip_api_url' => '',
'geoip_api_key' => '',
'retention_days' => 400,
],
];
foreach ($sectionDefaults as $section => $defaults) {
$config[$section] = array_merge($defaults, is_array($config[$section] ?? null) ? $config[$section] : []);
}
// Convert relative paths to absolute
$projectRoot = __DIR__ . '/../../';
if (isset($config['content_dir']) && strpos($config['content_dir'], '/') !== 0) {
+3
View File
@@ -36,6 +36,9 @@ if (file_exists($autoloader)) {
require_once 'class/Cache.php';
require_once 'class/RateLimiter.php';
require_once 'class/BotGuard.php';
require_once 'class/RequestLogger.php';
require_once 'class/GeoIP.php';
require_once 'class/Analytics.php';
require_once 'class/SimpleTemplate.php';
// Load Logger class - structured logging with log levels
+9
View File
@@ -40,5 +40,14 @@
"custom_blocked_agents": [],
"blocked_ips": [],
"allowed_ips": []
},
"analytics": {
"enabled": true,
"anonymize_ip": false,
"geoip_provider": "local",
"geoip_mmdb_path": "",
"geoip_api_url": "",
"geoip_api_key": "",
"retention_days": 400
}
}
+1
View File
@@ -303,6 +303,7 @@ Media files (images, PDFs, video, audio) can be placed in any `content/` subdire
| `content-dir-delete` | Delete directory (empty only) |
| `config` | Configuration form (title, homepage, language, SEO, author, features) |
| `security` | Security settings (bot/AI blocking, rate limiting, IP block/allowlist) |
| `statistics` | Visitor statistics with world map, countries, pages and GeoIP settings |
| `update` | One-click system update via Git pull |
| `theme` | Theme management |
| `plugins` | Plugin overview |
+4
View File
@@ -89,6 +89,9 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
- **Handleiding** - Ingebouwde documentatie met API referentie
- **Systeem Update** - Eenvoudig de site bijwerken via `/admin/update` met één klik via Git pull
- **Git-safe Configuratie** - `config.json` en `admin/config/admin.json` worden automatisch gegenereerd als ze niet bestaan en zijn uitgesloten van Git, waardoor wachtwoorden en instellingen behouden blijven bij updates
- **Bot & AI blokkering** - Instelbare blokkering van AI-crawlers, scrapers, lege user-agents en zoekmachines via `/admin/security`, met rate limiting per IP en IP block/allowlist
- **Bezoekersstatistieken** - Wereldkaart met bezoekers per land, unieke bezoekers, meest gelezen pagina's, verwijzende sites en een dagelijkse grafiek via `/admin/statistics`
- **GeoIP** - Landbepaling via een lokale DB-IP Lite database (offline, privacy-vriendelijk), of een eigen MaxMind `.mmdb` bestand of externe API
- **Bot & AI blokkering** - Automatische 403 voor bekende bots en AI-crawlers
- Session-based authenticatie met bcrypt hashing
- CSRF-bescherming, brute-force lockout (5 pogingen, 15 min)
@@ -305,6 +308,7 @@ Media bestanden (afbeeldingen, PDFs, video, audio) kunnen in elke `content/` sub
| `content-dir-delete` | Map verwijderen (alleen leeg) |
| `config` | Configuratieformulier (titel, startpagina, taal, SEO, auteur, features) |
| `security` | Beveiligingsinstellingen (bot/AI blokkering, rate limiting, IP block/allowlist) |
| `statistics` | Bezoekersstatistieken met wereldkaart, landen, pagina's en GeoIP-instellingen |
| `theme` | Thema beheer |
| `plugins` | Plugin overzicht |
| `plugins-new` | Nieuwe plugin aanmaken |
+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
+17 -2
View File
@@ -6,12 +6,27 @@
*/
return [
'version' => '1.8.0',
'version' => '1.9.0',
'release_date' => '2026-07-29',
'codename' => 'BotGuard',
'codename' => 'Atlas',
'status' => 'stable',
'changelog' => [
'1.9.0' => [
'date' => '2026-07-29',
'changes' => [
'Visitor statistics dashboard (/admin/statistics) with SVG world map choropleth',
'GeoIP country resolution with provider chain: local DB-IP Lite, MaxMind .mmdb, or external API',
'Built-in pure-PHP MaxMind DB reader (no Composer dependency required)',
'cli/geoip-update.php downloads DB-IP Lite and builds a compact binary index',
'cli/generate-world-map.php generates the world map SVG from Natural Earth TopoJSON',
'Aggregated statistics in admin/storage/stats.json (survives log clearing)',
'Unique visitors, human vs bot split, top pages, referrers and daily chart',
'Period filter (7 / 30 / 90 days / all time)',
'Optional IP anonymization and configurable retention period',
'Country column with flags in the request log and KPI cards on the dashboard',
]
],
'1.8.0' => [
'date' => '2026-07-29',
'changes' => [