Files
CodePress/cms/core/class/RequestLogger.php
T

366 lines
13 KiB
PHP

<?php
/**
* Logging en analyse van inkomende requests.
*
* Biedt functionaliteit om inkomende requests naar een logbestand te
* schrijven, IP-adressen te anonimiseren, IP's tegen lijsten/CIDR-ranges
* te matchen, het client-IP te bepalen en bezoekerstype (bot/mens/AI)
* te detecteren.
*
* @since 2.6.4
*/
class RequestLogger
{
/**
* Pad naar het logbestand.
*
* @since 2.6.4
* @var string
*/
private string $logFile;
/**
* Maak een nieuwe RequestLogger aan.
*
* @since 2.6.4
*
* @param string $logFile Pad naar het te gebruiken logbestand.
*/
public function __construct(string $logFile)
{
$this->logFile = $logFile;
}
/**
* Schrijf een request-regel naar het logbestand.
*
* De gebruikersagent en referrer worden ontdaan van controle-tekens en
* afgekapt tot 500 tekens. De landcode wordt alleen opgenomen wanneer
* deze exact twee tekens lang is. De mapstructuur wordt automatisch
* aangemaakt indien deze nog niet bestaat.
*
* @since 2.6.4
*
* @param string $page Opgevraagde pagina of route.
* @param string $ip Client IP-adres.
* @param string $userAgent HTTP User-Agent header.
* @param string $referrer HTTP Referer header.
* @param string $host Hostnaam van het request.
* @param string $acceptLanguage HTTP Accept-Language header.
* @param string $status Statusaanduiding van het request. Default 'ok'.
* @param string|null $country Tweeletterige landcode of null. Default null.
* @return 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)) {
@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);
}
/**
* Anonimiseer het laatste deel van een IP-adres.
*
* Voor IPv4 wordt het laatste octet vervangen door 'x'. Voor IPv6 worden
* alleen de eerste vier blokken behouden en de rest vervangen door '::x'.
* Ongeldige adressen worden ongewijzigd teruggegeven.
*
* @since 2.6.4
*
* @param string $ip Het te anonimiseren IP-adres.
* @return string Het geanonimiseerde IP-adres of het origineel bij een ongeldig adres.
*/
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;
}
/**
* Controleer of een IP overeenkomt met een entry uit een lijst van IP's of CIDR-ranges.
*
* Ondersteunt exacte IPv4/IPv6-adressen en CIDR-notatie (bijv. 192.168.0.0/16).
* Bij een prefix-lengte van 0 komt ieder IP van dezelfde adresfamilie overeen.
* Adresfamilies (IPv4 vs IPv6) worden niet onderling gemengd.
*
* @since 2.6.4
*
* @param string $ip Het te testen client IP-adres.
* @param string[] $list Lijst van IP-adressen en/of CIDR-ranges.
* @return bool True wanneer het IP met een entry overeenkomt.
*/
public static function ipMatchesList(string $ip, array $list): bool
{
$ip = trim($ip);
if ($ip === '') {
return false;
}
$isV6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
$packed = $isV6 ? inet_pton($ip) : inet_pton($ip);
foreach ($list as $entry) {
$entry = trim((string)$entry);
if ($entry === '') {
continue;
}
// Exact match
if ($entry === $ip) {
return true;
}
// CIDR notation
if (strpos($entry, '/') !== false) {
[$subnet, $bits] = array_pad(explode('/', $entry, 2), 2, null);
$subnet = trim($subnet);
$subnetPacked = inet_pton($subnet);
if ($subnetPacked === false || $packed === false) {
continue;
}
// Ensure both are the same address family
if (strlen($subnetPacked) !== strlen($packed)) {
continue;
}
$maxBits = strlen($packed) * 8;
$bits = (int)$bits;
if ($bits < 0 || $bits > $maxBits) {
continue;
}
if ($bits === 0) {
return true;
}
$fullBytes = intdiv($bits, 8);
$remainingBits = $bits % 8;
$match = true;
for ($i = 0; $i < $fullBytes; $i++) {
if ($subnetPacked[$i] !== $packed[$i]) {
$match = false;
break;
}
}
if ($match && $remainingBits > 0) {
$mask = 0xFF << (8 - $remainingBits);
if ((ord($subnetPacked[$fullBytes]) & $mask) !== (ord($packed[$fullBytes]) & $mask)) {
$match = false;
}
}
if ($match) {
return true;
}
}
}
return false;
}
/**
* Bepaal het client IP-adres uit de beschikbare request-headers.
*
* Doorloopt in twee passes een vaste set headers (zoals CF-Connecting-IP,
* X-Real-IP, X-Forwarded-For en REMOTE_ADDR). De eerste pass geeft
* voorrang aan geldige publieke IP's en slaat interne/reserved ranges
* over; de tweede pass is een fallback voor lokale ontwikkelomgevingen.
*
* @since 2.6.4
*
* @return string Het gedetecteerde client IP-adres, of '127.0.0.1' als fallback.
*/
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';
}
/**
* Detecteer het type bezoeker aan de hand van de User-Agent.
*
* Wanneer de BotGuard-class beschikbaar is wordt de UA geïdentificeerd
* en een passend label, badge-kleur en Bootstrap-icon teruggegeven
* (ai, search, scraper, generieke bot, lege UA of mens). Bij een
* ingelogde gebruiker wordt de naam aan het label toegevoegd. Zonder
* BotGuard wordt een algemene bezoeker teruggegeven.
*
* @since 2.6.4
*
* @param string $ua De User-Agent string.
* @param string $user Optionele gebruikersnaam; bij 'Gast' of leeg wordt geen naam toegevoegd. Default ''.
* @return array<string,string> Associatieve array met keys: type, label, badge, icon.
*/
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'];
}
/**
* Detecteer of het huidige request afkomstig is van een bekende bot.
*
* Leest de User-Agent uit de request-headers en identificeert deze via
* BotGuard wanneer beschikbaar. Alleen de categorieën 'ai', 'search'
* en 'scraper' worden als bot beschouwd en als uppercase string
* teruggegeven; anders null.
*
* @since 2.6.4
*
* @return string|null Boticategorie ('AI', 'SEARCH' of 'SCRAPER') of null bij geen bot.
*/
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;
}
/**
* Lees de laatste regels uit het logbestand en parse deze.
*
* De regels worden geparseerd volgens het vaste logformaat en omgezet
* naar associatieve arrays met tijd, IP, gebruiker, bezoeker-info, taal,
* pagina, UA, referrer, status en landcode. Regels waarvan de host leeg
* is of een IP/cli-waarde bevat worden als gebruiker 'Gast' gemarkeerd.
* Het resultaat is nieuwste-regel-eerst.
*
* @since 2.6.4
*
* @param int $lines Aantal te lezen regels vanaf het einde. Default 100.
* @return array<int,array<string,mixed>> Geparseerde logregels, nieuwste eerst. Lege array bij ontbrekend bestand.
*/
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);
}
}