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); } } /** * Send a log entry to a remote syslog server over UDP. */ 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); } } /** * Store a log entry in the SQLite database. */ 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); } } /** * Append a log entry to a plain text file (fallback driver). */ 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); } /** * Get (and lazily create) the PDO connection to the SQLite database. */ 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; } } /** * Whether the SQLite PDO driver is available. */ private static function sqliteAvailable(): bool { return class_exists('PDO') && in_array('sqlite', \PDO::getAvailableDrivers(), true); } /** * Map a facility name to its syslog numeric value. */ 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; } /** * Map a log level to its syslog severity value. */ 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; } /** * Query recent log entries from the active store. * * @param int $limit Number of entries to return * @param string|null $event Optional event filter * @param string|null $level Optional level filter (info, warning, error, ...) * @param string|null $search Optional text search on the message * @return array List of log entries (newest first) */ 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); } /** * Clear all stored log entries. */ 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, ''); } } }