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:
@@ -17,6 +17,8 @@ Thumbs.db
|
||||
.cache/
|
||||
.sass-cache/
|
||||
admin/storage/cache/
|
||||
admin/storage/geoip/
|
||||
admin/storage/stats.json
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'] ?>">
|
||||
|
||||
@@ -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') ?> ·
|
||||
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 & 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 ·
|
||||
<?= number_format((int)($geoMeta['ipv6_records'] ?? 0), 0, ',', '.') ?> IPv6 ·
|
||||
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) · 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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' => [
|
||||
|
||||
Reference in New Issue
Block a user