- 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
532 lines
17 KiB
PHP
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, '');
|
|
}
|
|
}
|
|
}
|