- 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
184 lines
6.3 KiB
PHP
184 lines
6.3 KiB
PHP
<?php
|
|
|
|
class RequestLogger
|
|
{
|
|
private string $logFile;
|
|
|
|
public function __construct(string $logFile)
|
|
{
|
|
$this->logFile = $logFile;
|
|
}
|
|
|
|
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)) {
|
|
@mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$timestamp = date('Y-m-d H:i:s');
|
|
$ua = substr(preg_replace('/[[:cntrl:]]/', '', $userAgent), 0, 500);
|
|
$ref = substr(preg_replace('/[[:cntrl:]]/', '', $referrer), 0, 500);
|
|
$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 = [
|
|
'HTTP_CF_CONNECTING_IP',
|
|
'HTTP_X_REAL_IP',
|
|
'HTTP_CLIENT_IP',
|
|
'HTTP_X_CLIENT_IP',
|
|
'HTTP_X_CLUSTER_CLIENT_IP',
|
|
'HTTP_X_FORWARDED_FOR',
|
|
'HTTP_X_FORWARDED',
|
|
'HTTP_FORWARDED_FOR',
|
|
'HTTP_FORWARDED',
|
|
'REMOTE_ADDR',
|
|
];
|
|
|
|
// Pass 1: Prioritize valid PUBLIC IP addresses (skips 127.0.0.1, 10.x, 172.x, 192.168.x proxy/internal IPs)
|
|
foreach ($headerKeys as $key) {
|
|
if (empty($_SERVER[$key])) continue;
|
|
|
|
$value = $_SERVER[$key];
|
|
$ips = str_contains($value, ',') ? explode(',', $value) : [$value];
|
|
|
|
foreach ($ips as $rawIp) {
|
|
$ip = trim($rawIp);
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) {
|
|
return $ip;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pass 2: Fallback for local development environments
|
|
foreach ($headerKeys as $key) {
|
|
if (empty($_SERVER[$key])) continue;
|
|
|
|
$value = $_SERVER[$key];
|
|
$ips = str_contains($value, ',') ? explode(',', $value) : [$value];
|
|
|
|
foreach ($ips as $rawIp) {
|
|
$ip = trim($rawIp);
|
|
if (filter_var($ip, FILTER_VALIDATE_IP) !== false) {
|
|
return $ip;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
|
}
|
|
|
|
public static function detectVisitorInfo(string $ua, string $user = ''): array
|
|
{
|
|
if (class_exists('BotGuard')) {
|
|
$id = BotGuard::identify($ua);
|
|
$cat = $id['category'];
|
|
$label = $id['label'];
|
|
|
|
if ($cat === 'ai') {
|
|
return ['type' => 'ai', 'label' => $label, 'badge' => 'danger', 'icon' => 'bi-robot'];
|
|
}
|
|
if ($cat === 'search') {
|
|
return ['type' => 'search', 'label' => $label, 'badge' => 'primary', 'icon' => 'bi-search'];
|
|
}
|
|
if ($cat === 'scraper') {
|
|
return ['type' => 'scraper', 'label' => $label, 'badge' => 'warning text-dark', 'icon' => 'bi-bug'];
|
|
}
|
|
if ($cat === 'generic') {
|
|
return ['type' => 'bot', 'label' => 'Bot', 'badge' => 'secondary', 'icon' => 'bi-robot'];
|
|
}
|
|
if ($cat === 'empty') {
|
|
return ['type' => 'empty', 'label' => 'Lege UA', 'badge' => 'secondary', 'icon' => 'bi-slash-circle'];
|
|
}
|
|
|
|
$userPrefix = ($user && $user !== 'Gast') ? htmlspecialchars($user) . ' (' : '';
|
|
$userSuffix = ($user && $user !== 'Gast') ? ')' : '';
|
|
|
|
return [
|
|
'type' => 'human',
|
|
'label' => $userPrefix . 'Mens' . $userSuffix,
|
|
'badge' => 'success',
|
|
'icon' => 'bi-person-check',
|
|
];
|
|
}
|
|
|
|
return ['type' => 'unknown', 'label' => 'Bezoeker', 'badge' => 'secondary', 'icon' => 'bi-person'];
|
|
}
|
|
|
|
public static function detectBot(): ?string
|
|
{
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
if (class_exists('BotGuard')) {
|
|
$id = BotGuard::identify($ua);
|
|
if (in_array($id['category'], ['ai', 'search', 'scraper'], true)) {
|
|
return strtoupper($id['category']);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function getLogs(int $lines = 100): array
|
|
{
|
|
if (!file_exists($this->logFile)) {
|
|
return [];
|
|
}
|
|
|
|
$content = file($this->logFile);
|
|
$content = array_slice($content, -$lines);
|
|
$logs = [];
|
|
|
|
foreach ($content as $line) {
|
|
$trimmed = trim($line);
|
|
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[] = [
|
|
'time' => $m[1],
|
|
'ip' => $m[2],
|
|
'user' => $user,
|
|
'visitor_info' => $visitorInfo,
|
|
'lang' => $m[4],
|
|
'page' => $m[5],
|
|
'ua' => $m[6],
|
|
'referrer' => $m[7],
|
|
'status' => $status,
|
|
'country' => $country,
|
|
];
|
|
}
|
|
}
|
|
|
|
return array_reverse($logs);
|
|
}
|
|
}
|