diff --git a/admin/templates/pages/logs.php b/admin/templates/pages/logs.php
index 7f4b6d5..bac2c78 100644
--- a/admin/templates/pages/logs.php
+++ b/admin/templates/pages/logs.php
@@ -68,6 +68,7 @@
| = htmlspecialchars($log['time']) ?> |
= htmlspecialchars($log['ip']) ?> |
+
+ = GeoIP::getCountryFlagEmoji($log['country'] ?: null) ?>
+ = htmlspecialchars($log['country'] ?: '—') ?>
+ |
= htmlspecialchars($log['page']) ?> |
diff --git a/admin/templates/pages/statistics.php b/admin/templates/pages/statistics.php
new file mode 100644
index 0000000..ac9a1ac
--- /dev/null
+++ b/admin/templates/pages/statistics.php
@@ -0,0 +1,368 @@
+ $maxDaily) $maxDaily = $d['views'];
+}
+
+$periodLabels = [7 => 'Laatste 7 dagen', 30 => 'Laatste 30 dagen', 90 => 'Laatste 90 dagen', 0 => 'Alles'];
+?>
+
+
+
+
+
+
+
+
+
+ Paginaweergaven
+ = number_format((int)($totals['views'] ?? 0), 0, ',', '.') ?>
+
+
+
+
+
+
+
+
+
+ Unieke bezoekers
+ = number_format((int)($totals['uniques'] ?? 0), 0, ',', '.') ?>
+
+
+
+
+
+
+
+
+
+ Mens / Bot
+
+ = number_format((int)($totals['human'] ?? 0), 0, ',', '.') ?>
+ /
+ = number_format((int)($totals['bot'] ?? 0), 0, ',', '.') ?>
+
+
+
+
+
+
+
+
+
+
+ Geblokkeerd
+ = number_format((int)($totals['blocked'] ?? 0), 0, ',', '.') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wereldkaart niet gevonden. Genereer deze met:
+ php cli/generate-world-map.php
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nog geen gegevens beschikbaar.
+
+
+ $count): ?>
+ 0 ? round(($count / $totalCountryViews) * 100, 1) : 0; ?>
+
+
+
+ = GeoIP::getCountryFlagEmoji($cc === 'UNKNOWN' ? null : $cc) ?>
+ = htmlspecialchars(GeoIP::getCountryName($cc === 'UNKNOWN' ? null : $cc)) ?>
+
+ = number_format($count, 0, ',', '.') ?> (= $pct ?>%)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nog geen gegevens beschikbaar.
+
+ $count): ?>
+ 0 ? round(($count / $maxPage) * 100, 1) : 0; ?>
+
+
+
+ = htmlspecialchars($pageName) ?>
+
+ = number_format($count, 0, ',', '.') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nog geen gegevens beschikbaar.
+
+ 0 ? ($chartW / $count) : 10;
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ Geen verwijzingen geregistreerd.
+
+
+ $count): ?>
+ -
+ = htmlspecialchars($host) ?>
+ = number_format($count, 0, ',', '.') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Database aanwezig
+
+ = number_format((int)($geoMeta['ipv4_records'] ?? 0), 0, ',', '.') ?> IPv4 ·
+ = number_format((int)($geoMeta['ipv6_records'] ?? 0), 0, ',', '.') ?> IPv6 ·
+ bijgewerkt = htmlspecialchars($geoMeta['updated'] ?? '-') ?>
+
+
+
+ Nog geen lokale database
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cli/generate-world-map.php b/cli/generate-world-map.php
new file mode 100644
index 0000000..3405b3c
--- /dev/null
+++ b/cli/generate-world-map.php
@@ -0,0 +1,159 @@
+#!/usr/bin/env php
+ ['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(
+ ' %s',
+ htmlspecialchars($alpha2),
+ htmlspecialchars($countryName),
+ trim($pathD),
+ htmlspecialchars($countryName)
+ );
+ }
+ }
+
+ $svgContent = '' . "\n";
+ $svgContent .= '';
+
+ 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";
+ }
+}
diff --git a/cli/geoip-update.php b/cli/geoip-update.php
new file mode 100644
index 0000000..3a6a573
--- /dev/null
+++ b/cli/geoip-update.php
@@ -0,0 +1,119 @@
+#!/usr/bin/env php
+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";
+}
diff --git a/cms/core/class/Analytics.php b/cms/core/class/Analytics.php
new file mode 100644
index 0000000..83dbbba
--- /dev/null
+++ b/cms/core/class/Analytics.php
@@ -0,0 +1,265 @@
+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)
+ ];
+ }
+}
diff --git a/cms/core/class/GeoIP.php b/cms/core/class/GeoIP.php
new file mode 100644
index 0000000..775ce12
--- /dev/null
+++ b/cms/core/class/GeoIP.php
@@ -0,0 +1,425 @@
+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)];
+ }
+ }
+}
diff --git a/cms/core/class/RequestLogger.php b/cms/core/class/RequestLogger.php
index 54d6fd6..3c8b635 100644
--- a/cms/core/class/RequestLogger.php
+++ b/cms/core/class/RequestLogger.php
@@ -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,
];
}
}
diff --git a/cms/core/config.php b/cms/core/config.php
index 565d06a..2af8926 100644
--- a/cms/core/config.php
+++ b/cms/core/config.php
@@ -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) {
diff --git a/cms/core/index.php b/cms/core/index.php
index ecf3045..128ccaf 100644
--- a/cms/core/index.php
+++ b/cms/core/index.php
@@ -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
diff --git a/config.json.example b/config.json.example
index 58fc7a3..2575f04 100644
--- a/config.json.example
+++ b/config.json.example
@@ -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
}
}
diff --git a/guide/en.codepress.md b/guide/en.codepress.md
index 7deceda..bf4e4e1 100644
--- a/guide/en.codepress.md
+++ b/guide/en.codepress.md
@@ -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 |
diff --git a/guide/nl.codepress.md b/guide/nl.codepress.md
index 62b1f15..f4ec5bc 100644
--- a/guide/nl.codepress.md
+++ b/guide/nl.codepress.md
@@ -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 |
diff --git a/public/admin.php b/public/admin.php
index bebc459..12b00e7 100644
--- a/public/admin.php
+++ b/public/admin.php
@@ -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);
diff --git a/public/assets/img/world-map.svg b/public/assets/img/world-map.svg
new file mode 100644
index 0000000..37735a2
--- /dev/null
+++ b/public/assets/img/world-map.svg
@@ -0,0 +1,154 @@
+
+
\ No newline at end of file
diff --git a/public/index.php b/public/index.php
index 4e41b29..cb0763c 100644
--- a/public/index.php
+++ b/public/index.php
@@ -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
diff --git a/version.php b/version.php
index bdadb73..bff124c 100644
--- a/version.php
+++ b/version.php
@@ -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' => [
|