CMS 2.0 - Theme engine, logging, admin improvements

Major changes:
- New ThemeManager with Twig templating and SCSS compilation
- Dynamic themes system (themes/default, themes/demo)
- LogManager with SQLite storage and syslog forwarding
- RequestLogger with static helper methods
- Admin UI overhaul (Bootstrap 5, dark mode)
- Admin config page with logging and theme settings
- Admin logs page with filters and search
- Removed legacy Mustache templates
- Removed test plugin and theme
- Composer dependencies: Twig, scssphp, CommonMark, MaxMind GeoIP
This commit is contained in:
2026-08-08 18:02:14 +02:00
parent cc0e4c19c8
commit a1e5baacac
833 changed files with 108974 additions and 1386 deletions
+365
View File
@@ -0,0 +1,365 @@
<?php
/**
* LogManager - Dynamic logging for CodePress CMS
*
* Supports three drivers:
* - syslog: Send log entries to a remote syslog server (UDP) or local syslog.
* - sqlite: Store log entries in a SQLite database (default when no syslog
* server is configured and SQLite is available).
* - file: Fallback to plain text files when SQLite is unavailable.
*
* Which event types are recorded is controlled dynamically via the
* "logging.events" config section (admin, requests, errors, security,
* content, system).
*/
class LogManager {
const EVENT_ADMIN = 'admin';
const EVENT_REQUESTS = 'requests';
const EVENT_ERRORS = 'errors';
const EVENT_SECURITY = 'security';
const EVENT_CONTENT = 'content';
const EVENT_SYSTEM = 'system';
private static $config = null;
private static $pdo = null;
private static $dbPath = null;
/**
* Initialize the log manager with the logging config section.
*
* @param array $loggingConfig The "logging" section from config.json
*/
public static function init(array $loggingConfig): void
{
self::$config = $loggingConfig;
self::$dbPath = dirname(__DIR__, 3) . '/admin/storage/logs/codepress.sqlite';
}
/**
* Whether logging is enabled at all.
*/
public static function isEnabled(): bool
{
return !empty(self::$config['enabled']);
}
/**
* Whether a given event type should be recorded.
*
* @param string $event One of the EVENT_* constants
*/
public static function isEventEnabled(string $event): bool
{
if (!self::isEnabled()) {
return false;
}
$events = self::$config['events'] ?? [];
return !empty($events[$event]);
}
/**
* Get the local storage driver: 'sqlite' (falling back to 'file' if
* SQLite is unavailable). Syslog is an additional output, not a storage
* driver, so it never replaces local storage.
*/
public static function getDriver(): string
{
$driver = self::$config['driver'] ?? 'sqlite';
if ($driver === 'sqlite') {
return self::sqliteAvailable() ? 'sqlite' : 'file';
}
return 'file';
}
/**
* Record a log entry if the event type is enabled.
*
* @param string $event Event type (EVENT_* constant)
* @param string $level Log level (info, warning, error, debug)
* @param string $message Log message
* @param array $context Additional structured context
*/
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);
}
}
/**
* 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, '');
}
}
}