Files
CodePress/cms/core/class/LogManager.php
T
E.Noorlander d9ea2eee47 v2.6.5 (Lyra): Dynamische pad-resolutie, WordPress-stijl docblocks, security-fix wachtwoord, git-historie schoon
- Bug: dashboard toonde 0 content (AdminPluginAPI::getContentDir() gaf relatief pad terug zonder normalisatie)
- Dynamische pad-resolutie: PluginAPIInterface uitgebreid met getProjectRoot/getContentDir/getPluginsDir/getVersionInfo; CMSAPI en AdminPluginAPI implementeren deze universeel
- public/index.php media-serving gebruikt $config['content_dir'] i.p.v. hardcoded /content
- Navigation en Logs plugins halen paden via de API i.p.v. hardcoded dirname(__DIR__)
- WordPress-stijl docblocks toegevoegd voor alle classes, methods, properties en functies (~450 docblocks, @since 2.6.5)
- Security: hardcoded plaintext-wachtwoord 'admin' verwijderd uit AdminAuth.php; bij eerste installatie wordt een cryptografisch veilig wachtwoord gegenereerd (random_bytes, 16 tekens) en eenmalig op het inlogscherm getoond
- Security: git-geschiedenis schoongemaakt (admin.json, admin.json.example, admin-console/config/admin.json verwijderd uit alle commits; filter-branch over alle branches + tags, gc --prune --aggressive)
- README.md, README.en.md, AGENTS.md bijgewerkt
- Test-scripts bijgewerkt naar clean-URL structuur + actuele ARIA-waarden
- Versie verhoogd naar 2.6.5
- Tests: pentest 29/29, WCAG 25/25, functioneel 16/16, enhanced 25/25
2026-08-27 09:05:51 +00:00

532 lines
17 KiB
PHP

<?php
/**
* Dynamische logging voor CodePress CMS.
*
* Ondersteunt drie drivers:
* - syslog: Stuurt log entries naar een remote syslog-server (UDP) of de
* lokale syslog.
* - sqlite: Slaat log entries op in een SQLite-database (standaard wanneer
* geen syslog-server geconfigureerd is en SQLite beschikbaar is).
* - file: Fallback naar platte-tekstbestanden wanneer SQLite niet
* beschikbaar is.
*
* Welke event-types geregistreerd worden, wordt dynamisch gestuurd via de
* "logging.events" configuratiesectie (admin, requests, errors, security,
* content, system).
*
* @since 2.6.5
*/
class LogManager {
/**
* Event-type voor admin-acties.
*
* @since 2.6.5
* @var string
*/
const EVENT_ADMIN = 'admin';
/**
* Event-type voor request-logging.
*
* @since 2.6.5
* @var string
*/
const EVENT_REQUESTS = 'requests';
/**
* Event-type voor fouten en waarschuwingen.
*
* @since 2.6.5
* @var string
*/
const EVENT_ERRORS = 'errors';
/**
* Event-type voor beveiligingsgebeurtenissen.
*
* @since 2.6.5
* @var string
*/
const EVENT_SECURITY = 'security';
/**
* Event-type voor content-wijzigingen.
*
* @since 2.6.5
* @var string
*/
const EVENT_CONTENT = 'content';
/**
* Event-type voor systeemgebeurtenissen.
*
* @since 2.6.5
* @var string
*/
const EVENT_SYSTEM = 'system';
/**
* De "logging" configuratiesectie, of null indien niet geïnitialiseerd.
*
* @since 2.6.5
* @var array|null
*/
private static $config = null;
/**
* Lazy-initialized PDO-verbinding met de SQLite-database, of null.
*
* @since 2.6.5
* @var \PDO|null
*/
private static $pdo = null;
/**
* Pad naar het SQLite-databasebestand, of null indien niet geïnitialiseerd.
*
* @since 2.6.5
* @var string|null
*/
private static $dbPath = null;
/**
* Initialiseert de log-manager met de logging-configuratie.
*
* Slaat de configuratie op en stelt het pad naar de SQLite-database in
* onder `admin/storage/logs`.
*
* @since 2.6.5
*
* @param array $loggingConfig De "logging" sectie uit config.json.
* @return void
*/
public static function init(array $loggingConfig): void
{
self::$config = $loggingConfig;
self::$dbPath = dirname(__DIR__, 3) . '/admin/storage/logs/codepress.sqlite';
}
/**
* Geeft aan of logging in het algemeen ingeschakeld is.
*
* @since 2.6.5
*
* @return bool True indien logging ingeschakeld is, anders false.
*/
public static function isEnabled(): bool
{
return !empty(self::$config['enabled']);
}
/**
* Geeft aan of een bepaald event-type geregistreerd moet worden.
*
* Controleert eerst of logging in het algemeen ingeschakeld is en
* daarna of het specifieke event aan staat in de configuratie.
*
* @since 2.6.5
*
* @param string $event Een van de EVENT_* constanten.
* @return bool True indien het event geregistreerd moet worden.
*/
public static function isEventEnabled(string $event): bool
{
if (!self::isEnabled()) {
return false;
}
$events = self::$config['events'] ?? [];
return !empty($events[$event]);
}
/**
* Geeft de actieve lokale opslag-driver.
*
* Retourneert 'sqlite' wanneer SQLite beschikbaar is, anders 'file'.
* Syslog is een aanvullende output en vervangt nooit de lokale
* opslag, en komt dus nooit als driver terug.
*
* @since 2.6.5
*
* @return string 'sqlite' of 'file'.
*/
public static function getDriver(): string
{
$driver = self::$config['driver'] ?? 'sqlite';
if ($driver === 'sqlite') {
return self::sqliteAvailable() ? 'sqlite' : 'file';
}
return 'file';
}
/**
* Slaat een log-entry op indien het event-type ingeschakeld is.
*
* De entry wordt altijd lokaal opgeslagen (SQLite of file-fallback) zodat
* de dynamische admin-log altijd entries bevat. Wanneer een syslog-host
* geconfigureerd is, wordt de entry tevens naar de remote syslog-server
* gestuurd. Het IP-adres wordt uit de context of de RequestLogger gehaald.
*
* @since 2.6.5
*
* @param string $event Event-type (een EVENT_* constante).
* @param string $level Loglevel (info, warning, error, debug).
* @param string $message Logbericht.
* @param array $context Aanvullende gestructureerde context. Default lege array.
* @return void
*/
public static function log(string $event, string $level, string $message, array $context = []): void
{
if (!self::isEventEnabled($event)) {
return;
}
$entry = [
'time' => date('Y-m-d H:i:s'),
'event' => $event,
'level' => $level,
'message' => $message,
'ip' => $context['ip'] ?? (class_exists('RequestLogger') ? RequestLogger::getClientIp() : ''),
'context' => $context,
];
$driver = self::getDriver();
// Always store locally (sqlite or file fallback) so the dynamic log
// in the admin always has entries.
if ($driver === 'sqlite') {
self::writeSqlite($entry);
} else {
self::writeFile($entry);
}
// Additionally forward to a remote syslog server if one is configured.
$syslogHost = trim(self::$config['syslog_host'] ?? '');
if ($syslogHost !== '') {
self::writeSyslog($entry);
}
}
/**
* Stuurt een log-entry naar een remote syslog-server over UDP.
*
* Bouwt een syslog-bericht op met facility, severity en ident en stuurt
* dit via een UDP-socket. Verbindings- en schrijffouten worden gesilenced.
*
* @since 2.6.5
*
* @param array $entry De te versturen log-entry.
* @return void
*/
private static function writeSyslog(array $entry): void
{
$host = trim(self::$config['syslog_host'] ?? '');
$port = (int)(self::$config['syslog_port'] ?? 514);
$facility = self::syslogFacility(self::$config['syslog_facility'] ?? 'local0');
$ident = self::$config['syslog_ident'] ?? 'codepress';
$severity = self::syslogSeverity($entry['level']);
$pri = ($facility * 8) + $severity;
$msg = '<' . $pri . '>' . date('M d H:i:s') . ' ' . $ident . '[' . getmypid() . ']: '
. '[' . $entry['event'] . '] [' . $entry['level'] . '] ' . $entry['message'];
$sock = @fsockopen('udp://' . $host, $port, $errno, $errstr, 2);
if ($sock) {
@fwrite($sock, $msg . "\n");
@fclose($sock);
}
}
/**
* Slaat een log-entry op in de SQLite-database.
*
* De context-array wordt als JSON opgeslagen. Wanneer de PDO-verbinding
* niet beschikbaar is of het schrijven faalt, wordt teruggevallen op
* de file-driver.
*
* @since 2.6.5
*
* @param array $entry De op te slaan log-entry.
* @return void
*/
private static function writeSqlite(array $entry): void
{
$pdo = self::getPdo();
if ($pdo === null) {
// SQLite failed -> fall back to file
self::writeFile($entry);
return;
}
try {
$stmt = $pdo->prepare(
'INSERT INTO logs (time, event, level, message, ip, context) VALUES (:time, :event, :level, :message, :ip, :context)'
);
$stmt->execute([
':time' => $entry['time'],
':event' => $entry['event'],
':level' => $entry['level'],
':message' => $entry['message'],
':ip' => $entry['ip'],
':context' => json_encode($entry['context']),
]);
} catch (\Throwable $e) {
self::writeFile($entry);
}
}
/**
* Voegt een log-entry toe aan een platte-tekstbestand (fallback-driver).
*
* Zorgt dat de doelmap bestaat en schrijft de entry met een vaste
* opmaak. Schrijffouten worden gesilenced.
*
* @since 2.6.5
*
* @param array $entry De toe te voegen log-entry.
* @return void
*/
private static function writeFile(array $entry): void
{
$dir = dirname(self::$dbPath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$file = $dir . '/codepress.log';
$line = '[' . $entry['time'] . '] [' . $entry['event'] . '] [' . $entry['level'] . '] ['
. $entry['ip'] . '] ' . $entry['message'] . "\n";
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
/**
* Geeft (en creëert lazy) de PDO-verbinding met de SQLite-database.
*
* Maakt de tabel en indices aan indien deze nog niet bestaan. Bij een
* fout wordt de interne verbinding op null gezet en null teruggegeven,
* zodat de aanroeper kan terugvallen op de file-driver.
*
* @since 2.6.5
*
* @return \PDO|null PDO-verbinding of null indien SQLite niet beschikbaar/faalt.
*/
private static function getPdo(): ?\PDO
{
if (self::$pdo !== null) {
return self::$pdo;
}
if (!self::sqliteAvailable()) {
return null;
}
$dir = dirname(self::$dbPath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
try {
self::$pdo = new \PDO('sqlite:' . self::$dbPath);
self::$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
self::$pdo->exec(
'CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
event TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
ip TEXT,
context TEXT
)'
);
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_event ON logs (event)');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_time ON logs (time)');
return self::$pdo;
} catch (\Throwable $e) {
self::$pdo = null;
return null;
}
}
/**
* Geeft aan of de SQLite PDO-driver beschikbaar is.
*
* @since 2.6.5
*
* @return bool True indien PDO en de sqlite-driver beschikbaar zijn.
*/
private static function sqliteAvailable(): bool
{
return class_exists('PDO') && in_array('sqlite', \PDO::getAvailableDrivers(), true);
}
/**
* Vertaalt een facility-naam naar de bijbehorende numerieke syslog-waarde.
*
* Ondersteunt de standaard facilities (kern, user, mail, daemon, auth,
* syslog, lpr, news, uucp, cron, authpriv, ftp) en local0 t/m local7.
* Onbekende faciliteiten vallen terug op local0 (16).
*
* @since 2.6.5
*
* @param string $facility Naam van de facility. Default 'local0' wordt door aanroeper gezet.
* @return int Numerieke facility-waarde.
*/
private static function syslogFacility(string $facility): int
{
$map = [
'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3,
'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7,
'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11,
'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19,
'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23,
];
return $map[$facility] ?? 16;
}
/**
* Vertaalt een loglevel naar de bijbehorende syslog-severity.
*
* Ondersteunt debug t/m emergency. Onbekende levels vallen terug op
* info (6). De invoer wordt case-insensitive vergeleken.
*
* @since 2.6.5
*
* @param string $level Loglevel (debug, info, notice, warning, error, ...).
* @return int Numerieke severity-waarde (0 t/m 7).
*/
private static function syslogSeverity(string $level): int
{
$map = [
'debug' => 7,
'info' => 6,
'notice' => 5,
'warning' => 4,
'error' => 3,
'critical' => 2,
'alert' => 1,
'emergency' => 0,
];
return $map[strtolower($level)] ?? 6;
}
/**
* Zoekt recente log-entries op uit de actieve opslag.
*
* Bij de SQLite-driver wordt een geparametriseerde query uitgevoerd met
* optionele filters op event, level en een zoekterm op het bericht.
* Bij de file-driver wordt het logbestand uitgelezen en met een regex
* geparseerd; de resultaten worden gefilterd en oudste eerst teruggegeven.
* Resultaten worden altijd nieuwste eerst geretourneerd (SQLite via
* ORDER BY id DESC, file via array_reverse).
*
* @since 2.6.5
*
* @param int $limit Aantal terug te geven entries. Default 200.
* @param string|null $event Optioneel event-filter. Default null (geen filter).
* @param string|null $level Optioneel levelfilter (info, warning, error, ...). Default null.
* @param string|null $search Optionele tekstzoekopdracht op het bericht. Default null.
* @return array<int, array<string, mixed>> Lijst met log-entries (nieuwste eerst).
*/
public static function getLogs(int $limit = 200, ?string $event = null, ?string $level = null, ?string $search = null): array
{
$driver = self::getDriver();
if ($driver === 'sqlite') {
$pdo = self::getPdo();
if ($pdo !== null) {
try {
$sql = 'SELECT time, event, level, message, ip FROM logs';
$conds = [];
$params = [];
if ($event !== null && $event !== '') {
$conds[] = 'event = :event';
$params[':event'] = $event;
}
if ($level !== null && $level !== '') {
$conds[] = 'level = :level';
$params[':level'] = $level;
}
if ($search !== null && $search !== '') {
$conds[] = 'message LIKE :search';
$params[':search'] = '%' . $search . '%';
}
if (!empty($conds)) {
$sql .= ' WHERE ' . implode(' AND ', $conds);
}
$sql .= ' ORDER BY id DESC LIMIT ' . (int)$limit;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} catch (\Throwable $e) {
return [];
}
}
}
// File fallback
$dir = dirname(self::$dbPath);
$file = $dir . '/codepress.log';
if (!file_exists($file)) {
return [];
}
$lines = file($file);
$lines = array_slice($lines, -$limit);
$logs = [];
foreach ($lines as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
if ($event !== null && $event !== '' && $m[2] !== $event) {
continue;
}
if ($level !== null && $level !== '' && $m[3] !== $level) {
continue;
}
if ($search !== null && $search !== '' && stripos($m[5], $search) === false) {
continue;
}
$logs[] = [
'time' => $m[1],
'event' => $m[2],
'level' => $m[3],
'ip' => $m[4],
'message' => $m[5],
];
}
}
return array_reverse($logs);
}
/**
* Wist alle opgeslagen log-entries.
*
* Bij de SQLite-driver worden alle rijen uit de logs-tabel verwijderd.
* Bij de file-driver wordt het logbestand leeggemaakt. Schrijffouten
* worden gesilenced.
*
* @since 2.6.5
*
* @return void
*/
public static function clear(): void
{
$driver = self::getDriver();
if ($driver === 'sqlite') {
$pdo = self::getPdo();
if ($pdo !== null) {
try {
$pdo->exec('DELETE FROM logs');
return;
} catch (\Throwable $e) {
// fall through to file
}
}
}
$dir = dirname(self::$dbPath);
$file = $dir . '/codepress.log';
if (file_exists($file)) {
@file_put_contents($file, '');
}
}
}