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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user