CodePress CMS v1.9.0: visitor statistics with SVG world map and GeoIP

- Add GeoIP class with provider chain: local DB-IP Lite, MaxMind .mmdb, external API
- Add built-in pure-PHP MMDBReader so .mmdb works without Composer
- Add cli/geoip-update.php to download DB-IP Lite and build a compact binary index
- Add cli/generate-world-map.php to generate the world map SVG from Natural Earth TopoJSON
- Add Analytics class aggregating stats in admin/storage/stats.json with LOCK_EX
- Add admin statistics page with choropleth world map, country list, top pages,
  daily chart, referrers and a period filter
- Add GeoIP and privacy settings with database update and stats reset buttons
- Add optional IP anonymization and configurable retention period
- Add country field to requests.log (parser accepts 7, 8 or 9 fields)
- Add country column to request log and KPI cards to the dashboard
- Ignore GeoIP binaries and stats.json in Git
- Update TODO.md, guides and version to 1.9.0
This commit is contained in:
2026-07-29 15:40:02 +02:00
parent a795307664
commit 5357bc8915
20 changed files with 1804 additions and 11 deletions
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env php
<?php
/**
* Natural Earth World Map SVG generator for CodePress CMS
* Converts Natural Earth 110m TopoJSON to SVG with ISO alpha-2 country IDs
*/
function generateWorldMapSvg(): bool
{
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
$imgDir = dirname($outPath);
if (!is_dir($imgDir)) {
@mkdir($imgDir, 0755, true);
}
$topoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
$cacheFile = '/tmp/world-atlas-110m.json';
if (!file_exists($cacheFile) || filesize($cacheFile) < 1000) {
$ctx = stream_context_create(['http' => ['timeout' => 15, 'user_agent' => 'CodePressCMS/1.9.0']]);
$data = @file_get_contents($topoUrl, false, $ctx);
if ($data) {
file_put_contents($cacheFile, $data);
}
}
if (!file_exists($cacheFile)) {
return false;
}
$topo = json_decode(file_get_contents($cacheFile), true);
if (!$topo || empty($topo['objects']['countries']['geometries'])) {
return false;
}
// Mapping ISO 3166-1 numeric code -> ISO 3166-1 alpha-2 code
$numericToAlpha2 = [
'4' => 'AF', '8' => 'AL', '12' => 'DZ', '20' => 'AD', '24' => 'AO', '28' => 'AG', '32' => 'AR', '51' => 'AM',
'36' => 'AU', '40' => 'AT', '31' => 'AZ', '44' => 'BS', '48' => 'BH', '50' => 'BD', '52' => 'BB', '112' => 'BY',
'56' => 'BE', '84' => 'BZ', '204' => 'BJ', '64' => 'BT', '68' => 'BO', '70' => 'BA', '72' => 'BW', '76' => 'BR',
'96' => 'BN', '100' => 'BG', '854' => 'BF', '108' => 'BI', '116' => 'KH', '120' => 'CM', '124' => 'CA', '132' => 'CV',
'140' => 'CF', '148' => 'TD', '152' => 'CL', '156' => 'CN', '170' => 'CO', '174' => 'KM', '178' => 'CG', '180' => 'CD',
'188' => 'CR', '384' => 'CI', '191' => 'HR', '192' => 'CU', '196' => 'CY', '203' => 'CZ', '208' => 'DK', '262' => 'DJ',
'212' => 'DM', '214' => 'DO', '218' => 'EC', '818' => 'EG', '222' => 'SV', '226' => 'GQ', '232' => 'ER', '233' => 'EE',
'231' => 'ET', '242' => 'FJ', '246' => 'FI', '250' => 'FR', '266' => 'GA', '270' => 'GM', '268' => 'GE', '276' => 'DE',
'288' => 'GH', '300' => 'GR', '308' => 'GD', '320' => 'GT', '324' => 'GN', '624' => 'GW', '328' => 'GY', '332' => 'HT',
'340' => 'HN', '348' => 'HU', '352' => 'IS', '356' => 'IN', '360' => 'ID', '364' => 'IR', '368' => 'IQ', '372' => 'IE',
'376' => 'IL', '380' => 'IT', '388' => 'JM', '392' => 'JP', '400' => 'JO', '398' => 'KZ', '404' => 'KE', '296' => 'KI',
'408' => 'KP', '410' => 'KR', '414' => 'KW', '417' => 'KG', '418' => 'LA', '428' => 'LV', '422' => 'LB', '426' => 'LS',
'430' => 'LR', '434' => 'LY', '438' => 'LI', '440' => 'LT', '442' => 'LU', '807' => 'MK', '450' => 'MG', '454' => 'MW',
'458' => 'MY', '462' => 'MV', '466' => 'ML', '470' => 'MT', '584' => 'MH', '478' => 'MR', '480' => 'MU', '484' => 'MX',
'583' => 'FM', '498' => 'MD', '492' => 'MC', '496' => 'MN', '499' => 'ME', '504' => 'MA', '508' => 'MZ', '104' => 'MM',
'516' => 'NA', '520' => 'NR', '524' => 'NP', '528' => 'NL', '554' => 'NZ', '558' => 'NI', '562' => 'NE', '566' => 'NG',
'578' => 'NO', '512' => 'OM', '586' => 'PK', '585' => 'PW', '591' => 'PA', '598' => 'PG', '600' => 'PY', '604' => 'PE',
'608' => 'PH', '616' => 'PL', '620' => 'PT', '634' => 'QA', '642' => 'RO', '643' => 'RU', '646' => 'RW', '659' => 'KN',
'662' => 'LC', '670' => 'VC', '882' => 'WS', '674' => 'SM', '678' => 'ST', '682' => 'SA', '686' => 'SN', '688' => 'RS',
'690' => 'SC', '694' => 'SL', '702' => 'SG', '703' => 'SK', '705' => 'SI', '90' => 'SB', '706' => 'SO', '710' => 'ZA',
'728' => 'SS', '724' => 'ES', '144' => 'LK', '729' => 'SD', '740' => 'SR', '748' => 'SZ', '752' => 'SE', '756' => 'CH',
'760' => 'SY', '158' => 'TW', '762' => 'TJ', '834' => 'TZ', '764' => 'TH', '626' => 'TL', '768' => 'TG', '776' => 'TO',
'780' => 'TT', '788' => 'TN', '792' => 'TR', '795' => 'TM', '798' => 'TV', '800' => 'UG', '804' => 'UA', '784' => 'AE',
'826' => 'GB', '840' => 'US', '858' => 'UY', '860' => 'UZ', '548' => 'VU', '862' => 'VE', '704' => 'VN', '887' => 'YE',
'894' => 'ZM', '716' => 'ZW', '-99' => 'XK'
];
// Decode TopoJSON arcs
$scale = $topo['transform']['scale'];
$translate = $topo['transform']['translate'];
$rawArcs = $topo['arcs'];
$decodedArcs = [];
foreach ($rawArcs as $arcIndex => $arc) {
$x = 0;
$y = 0;
$points = [];
foreach ($arc as $position) {
$x += $position[0];
$y += $position[1];
// Equirectangular projection mapping to 1000x500 canvas
$lon = $x * $scale[0] + $translate[0];
$lat = $y * $scale[1] + $translate[1];
$svgX = round(($lon + 180) * (1000 / 360), 2);
$svgY = round((90 - $lat) * (500 / 180), 2);
$points[] = [$svgX, $svgY];
}
$decodedArcs[$arcIndex] = $points;
}
$svgPaths = [];
foreach ($topo['objects']['countries']['geometries'] as $geo) {
$numericId = (string)($geo['id'] ?? '');
$countryName = $geo['properties']['name'] ?? 'Unknown';
$alpha2 = $numericToAlpha2[$numericId] ?? null;
if (!$alpha2) continue;
$pathD = '';
$type = $geo['type'];
$arcsList = ($type === 'Polygon') ? [$geo['arcs']] : (($type === 'MultiPolygon') ? $geo['arcs'] : []);
foreach ($arcsList as $polygon) {
foreach ($polygon as $ringIndex => $ring) {
$ringPoints = [];
foreach ($ring as $arcIdx) {
$reversed = $arcIdx < 0;
$actualIdx = $reversed ? ~$arcIdx : $arcIdx;
$arcPoints = $decodedArcs[$actualIdx] ?? [];
if ($reversed) {
$arcPoints = array_reverse($arcPoints);
}
if (!empty($ringPoints)) {
array_shift($arcPoints); // Skip duplicate start point
}
$ringPoints = array_merge($ringPoints, $arcPoints);
}
if (empty($ringPoints)) continue;
$pathD .= 'M' . $ringPoints[0][0] . ',' . $ringPoints[0][1] . ' ';
for ($p = 1; $p < count($ringPoints); $p++) {
$pathD .= 'L' . $ringPoints[$p][0] . ',' . $ringPoints[$p][1] . ' ';
}
$pathD .= 'Z ';
}
}
if (trim($pathD) !== '') {
$svgPaths[] = sprintf(
' <path id="%s" data-name="%s" class="country" d="%s"><title>%s</title></path>',
htmlspecialchars($alpha2),
htmlspecialchars($countryName),
trim($pathD),
htmlspecialchars($countryName)
);
}
}
$svgContent = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$svgContent .= '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500" width="100%" height="auto" class="codepress-world-map">' . "\n";
$svgContent .= ' <style>' . "\n";
$svgContent .= ' .country { fill: #e9ecef; stroke: #ffffff; stroke-width: 0.6; stroke-linejoin: round; transition: fill 0.2s ease; cursor: pointer; }' . "\n";
$svgContent .= ' .country:hover { fill: #0d6efd !important; opacity: 0.9; }' . "\n";
$svgContent .= ' </style>' . "\n";
$svgContent .= ' <rect width="1000" height="500" fill="#f8f9fa"/>' . "\n";
$svgContent .= implode("\n", $svgPaths) . "\n";
$svgContent .= '</svg>';
file_put_contents($outPath, $svgContent);
return true;
}
if (php_sapi_name() === 'cli') {
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
if (generateWorldMapSvg()) {
echo "Wereldkaart SVG succesvol aangemaakt in public/assets/img/world-map.svg!\n";
} else {
echo "Fout bij genereren van wereldkaart SVG.\n";
}
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* DB-IP Lite database downloader & binary converter for CodePress CMS
*/
if (php_sapi_name() !== 'cli' && (!isset($_SESSION['admin_user']))) {
// Can also be included from admin handler
}
function updateGeoIPDatabase(): array
{
$baseDir = dirname(__DIR__) . '/admin/storage/geoip';
if (!is_dir($baseDir)) {
@mkdir($baseDir, 0755, true);
}
$currentDate = new DateTime('first day of this month');
$urls = [];
for ($i = 0; $i < 3; $i++) {
$ym = $currentDate->format('Y-m');
$urls[] = "https://download.db-ip.com/free/dbip-country-lite-{$ym}.csv.gz";
$currentDate->modify('-1 month');
}
$downloadUrl = null;
$gzContent = null;
foreach ($urls as $url) {
$ctx = stream_context_create(['http' => ['timeout' => 15, 'user_agent' => 'CodePressCMS/1.9.0']]);
$data = @file_get_contents($url, false, $ctx);
if ($data !== false && strlen($data) > 1000) {
$downloadUrl = $url;
$gzContent = $data;
break;
}
}
if (!$gzContent) {
return ['success' => false, 'message' => 'Kon DB-IP Lite database niet downloaden vanaf DB-IP.com.'];
}
$csvData = @gzdecode($gzContent);
if (!$csvData) {
return ['success' => false, 'message' => 'Kon gecomprimeerde DB-IP database niet uitpakken.'];
}
$ipv4BinPath = $baseDir . '/ipv4.bin';
$ipv6BinPath = $baseDir . '/ipv6.bin';
$v4Handle = fopen($ipv4BinPath, 'wb');
$v6Handle = fopen($ipv6BinPath, 'wb');
$lines = explode("\n", $csvData);
$v4Count = 0;
$v6Count = 0;
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
$parts = str_getcsv($line);
if (count($parts) < 3) continue;
$startIp = trim($parts[0]);
$endIp = trim($parts[1]);
$country = strtoupper(trim($parts[2]));
if (strlen($country) !== 2) continue;
if (filter_var($startIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$startLong = ip2long($startIp);
$endLong = ip2long($endIp);
if ($startLong !== false && $endLong !== false) {
// Pack 4-byte uint32 start, 4-byte uint32 end, 2-byte country code
$record = pack('NNa2', $startLong, $endLong, $country);
fwrite($v4Handle, $record);
$v4Count++;
}
} elseif (filter_var($startIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$startBin = inet_pton($startIp);
$endBin = inet_pton($endIp);
if ($startBin !== false && $endBin !== false) {
// Pack 16-byte start, 16-byte end, 2-byte country code
$record = $startBin . $endBin . $country;
fwrite($v6Handle, $record);
$v6Count++;
}
}
}
fclose($v4Handle);
fclose($v6Handle);
$meta = [
'source' => 'DB-IP Lite',
'attribution' => 'IP geolocation by DB-IP (https://dbip.com)',
'updated' => date('Y-m-d H:i:s'),
'url' => $downloadUrl,
'ipv4_records' => $v4Count,
'ipv6_records' => $v6Count,
'ipv4_size' => filesize($ipv4BinPath),
'ipv6_size' => filesize($ipv6BinPath),
];
file_put_contents($baseDir . '/meta.json', json_encode($meta, JSON_PRETTY_PRINT));
return [
'success' => true,
'message' => "GeoIP database succesvol bijgewerkt! ({$v4Count} IPv4, {$v6Count} IPv6 records)",
'meta' => $meta
];
}
if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
echo "DB-IP Lite database bijwerken...\n";
$res = updateGeoIPDatabase();
echo $res['message'] . "\n";
}