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
+53 -50
View File
@@ -591,7 +591,7 @@ class CodePressCMS {
}
}
$authorWebsite = $this->config['author']['website'] ?? '';
$authorWebsite = $this->normalizeUrl($this->config['author']['website'] ?? '');
if ($authorWebsite !== '') {
$authorHost = parse_url($authorWebsite, PHP_URL_HOST);
if ($authorHost) {
@@ -602,6 +602,25 @@ class CodePressCMS {
return array_values(array_unique(array_filter($hosts)));
}
/**
* Normalize a URL: if no scheme is present, prepend https://.
* Handles hostnames stored without protocol (e.g. "noorlander.info").
*
* @param string $url Raw URL or hostname
* @return string Normalized absolute URL, or '' if empty
*/
private function normalizeUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) {
return 'https://' . $url;
}
return $url;
}
/**
* Parse Markdown content to HTML using League CommonMark
*
@@ -1140,7 +1159,7 @@ class CodePressCMS {
'is_guide_page' => isset($_GET['guide']),
'lang_switch_url' => '',
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $this->config['author']['website'] ?? '#',
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
'author_git' => 'https://git.noorlander.info/E.Noorlander',
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
@@ -1156,8 +1175,6 @@ class CodePressCMS {
'nav_height' => $this->config['theme']['nav_height'] ?? '42',
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
'background_image_css' => $this->getBackgroundImageCss(),
'background_image_opacity' => $this->getBackgroundImageOpacity(),
// Language
'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage),
@@ -1216,36 +1233,21 @@ class CodePressCMS {
// Don't show site title link on guide page
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
// Load and render all templates with data
$layoutTemplate = file_get_contents($this->config['templates_dir'] . '/layout.mustache');
$headerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/header.mustache');
$navigationTemplate = file_get_contents($this->config['templates_dir'] . '/assets/navigation.mustache');
$footerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache');
// Determine content type and load appropriate template
$contentType = $this->getContentType($page);
$contentTemplateFile = $this->config['templates_dir'] . '/' . $contentType . '_content.mustache';
$contentTemplate = file_exists($contentTemplateFile) ? file_get_contents($contentTemplateFile) : '<div class="content">{{{content}}}</div>';
// Map legacy frontmatter layout values to theme template keys
$layoutKey = $this->mapLayoutToThemeKey($layout);
// Add theme asset URLs to template data
$themeManager = new ThemeManager($this->config);
$templateData['theme_title'] = $themeManager->getTitle();
$templateData['theme_css_url'] = $themeManager->getCssUrl();
$templateData['theme_js_url'] = $themeManager->getJsUrl();
$templateData['theme_config'] = $themeManager->getConfig();
// Render the page through the active theme
$renderedLayout = $themeManager->render($layoutKey, $templateData);
// Render all templates with data
$renderedHeader = SimpleTemplate::render($headerTemplate, $templateData);
$renderedNavigation = SimpleTemplate::render($navigationTemplate, $templateData);
$renderedFooter = SimpleTemplate::render($footerTemplate, $templateData);
$renderedContent = SimpleTemplate::render($contentTemplate, $templateData);
// Replace partials in layout
$finalTemplate = str_replace('{{>header}}', $renderedHeader, $layoutTemplate);
$finalTemplate = str_replace('{{>navigation}}', $renderedNavigation, $finalTemplate);
$finalTemplate = str_replace('{{>footer}}', $renderedFooter, $finalTemplate);
$finalTemplate = str_replace('{{>content_template}}', $renderedContent, $finalTemplate);
// Render the final layout with all template data
$renderedLayout = SimpleTemplate::render($finalTemplate, $templateData);
echo $renderedLayout;
$this->pluginManager->doAction('onAfterRender', $renderedLayout);
}
@@ -1371,6 +1373,25 @@ class CodePressCMS {
return $html;
}
/**
* Map a frontmatter layout value to a theme template key.
*
* Legacy values are translated to the new theme keys. Unknown values
* are passed through so ThemeManager can fall back to default_layout.
*
* @param string $layout Layout value from page metadata
* @return string Theme template key
*/
private function mapLayoutToThemeKey(string $layout): string {
return match ($layout) {
'content' => 'full_content',
'sidebar-content', 'content-sidebar' => 'left_sidebar',
'content-sidebar-reverse' => 'right_sidebar',
'sidebar' => 'custom1',
default => $layout,
};
}
/**
* Determine content type for current page
*
@@ -1540,24 +1561,6 @@ class CodePressCMS {
return false;
}
private function getBackgroundImageCss(): string
{
$bg = $this->config['theme']['background_image'] ?? '';
if (empty($bg)) {
return 'none';
}
if (str_starts_with($bg, 'http')) {
return 'url(' . $bg . ')';
}
return 'url(/themes/' . $bg . ')';
}
private function getBackgroundImageOpacity(): int
{
$opacity = intval($this->config['theme']['background_image_opacity'] ?? 100);
return max(0, min(100, $opacity));
}
private function processContent(string $content): string
{
return str_replace('-/assets/', '/-assets/', $content);
+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, '');
}
}
}
+8
View File
@@ -98,6 +98,14 @@ class Logger {
// Write to file with error suppression (graceful degradation)
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
// Route through the dynamic log manager (errors/system events)
if (class_exists('LogManager')) {
$event = ($level === self::ERROR || $level === self::WARNING)
? LogManager::EVENT_ERRORS
: LogManager::EVENT_SYSTEM;
LogManager::log($event, strtolower($level), $message, $context);
}
}
/**
+73
View File
@@ -44,6 +44,79 @@ class RequestLogger
return $ip;
}
/**
* Check whether an IP matches any entry in a list of IPs/CIDR ranges.
*
* Supports exact IPv4/IPv6 addresses and CIDR notation (e.g. 192.168.0.0/16).
*
* @param string $ip The client IP to test
* @param array $list List of IPs and/or CIDR ranges
* @return bool True if the IP matches any entry
*/
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;
}
public static function getClientIp(): string
{
$headerKeys = [
+203
View File
@@ -0,0 +1,203 @@
<?php
use ScssPhp\ScssPhp\Compiler;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* ThemeManager - Resolves and renders the active theme
*
* Responsibilities:
* - Resolve the active theme directory from config
* - Load theme.json (title, default_layout, template mapping, colors)
* - Build a Twig environment rooted at the theme directory
* - Compile theme SCSS to CSS at runtime (cached by mtime)
* - Map a requested layout to a concrete .twig template, falling back
* to the theme's default_layout when the layout is unknown
*/
class ThemeManager {
private $config;
private $themeDir;
private $themeConfig;
private $twig;
private $compiledCssDir;
/**
* @param array $config Full CMS config (must contain 'theme_dir' and 'theme')
*/
public function __construct(array $config) {
$this->config = $config;
$this->themeDir = $config['theme_dir'] ?? (__DIR__ . '/../../../themes/' . ($config['active_theme'] ?? 'default'));
$this->themeConfig = $config['theme'] ?? [];
$this->compiledCssDir = __DIR__ . '/../../../public/themes';
$loader = new FilesystemLoader($this->themeDir);
$this->twig = new Environment($loader, [
'cache' => false,
'autoescape' => false,
]);
}
/**
* Get the absolute path of the active theme directory
*/
public function getThemeDir(): string {
return $this->themeDir;
}
/**
* Get the raw theme.json config array
*/
public function getThemeConfig(): array {
return $this->themeConfig;
}
/**
* Get the theme title (from theme.json 'title' or 'name')
*/
public function getTitle(): string {
return $this->themeConfig['title'] ?? $this->themeConfig['name'] ?? basename($this->themeDir);
}
/**
* Get the theme config section (default_template, background settings, etc.)
*/
public function getConfig(): array {
return $this->themeConfig['config'] ?? [];
}
/**
* Get the theme template section (layout key => .twig file mapping)
*/
public function getTemplates(): array {
return $this->themeConfig['template'] ?? [];
}
/**
* Resolve the .twig template file for a requested layout.
*
* Templates are defined in theme.json under the "template" section:
* { "template": { "full_content": "full_content.twig", ... } }
* The default template is defined in the "config" section:
* { "config": { "default_template": "full_content", ... } }
*
* Priority:
* 1. If the layout is a known key in the "template" section, use its mapped file.
* 2. Otherwise fall back to config.default_template.
* 3. Final safety net: full_content.twig.
*
* @param string $layout Requested layout key (e.g. 'left_sidebar')
* @return string Template name usable by the Twig loader
*/
public function getTemplateForLayout(string $layout): string {
$layout = trim($layout);
$templates = $this->getTemplates();
if ($layout !== '' && isset($templates[$layout])) {
$file = $templates[$layout];
if ($this->templateExists($file)) {
return $file;
}
}
$config = $this->getConfig();
$default = $config['default_template'] ?? 'full_content';
if (isset($templates[$default])) {
$file = $templates[$default];
if ($this->templateExists($file)) {
return $file;
}
}
return 'full_content.twig';
}
/**
* Get the list of available layout keys defined in the "template" section.
*
* @return array List of layout keys
*/
public function getLayouts(): array {
return array_keys($this->getTemplates());
}
/**
* Check whether a template file exists in the theme directory
*/
private function templateExists(string $file): bool {
$path = $this->themeDir . '/' . ltrim($file, '/');
return is_file($path);
}
/**
* Render a layout template with the given data.
*
* @param string $layout Requested layout key
* @param array $data Template variables
* @return string Rendered HTML
*/
public function render(string $layout, array $data): string {
$template = $this->getTemplateForLayout($layout);
return $this->twig->render($template, $data);
}
/**
* Compile the theme's SCSS to CSS (cached by source mtime).
*
* @return string|null Absolute path to the compiled CSS, or null if none
*/
public function compileCss(): ?string {
$scssFile = $this->themeDir . '/css/theme.scss';
if (!is_file($scssFile)) {
return null;
}
$themeName = basename($this->themeDir);
$outDir = $this->compiledCssDir . '/' . $themeName;
$outFile = $outDir . '/theme.css';
$cacheFile = $outDir . '/.mtime';
$mtime = filemtime($scssFile);
if (is_file($outFile) && is_file($cacheFile) && (int)file_get_contents($cacheFile) === $mtime) {
return $outFile;
}
if (!is_dir($outDir)) {
mkdir($outDir, 0755, true);
}
try {
$compiler = new Compiler();
$compiler->setImportPaths($this->themeDir . '/css');
$css = $compiler->compileString(file_get_contents($scssFile))->getCss();
file_put_contents($outFile, $css);
file_put_contents($cacheFile, (string)$mtime);
return $outFile;
} catch (\Throwable $e) {
error_log('ThemeManager SCSS compile error: ' . $e->getMessage());
return null;
}
}
/**
* Get the public URL for the compiled theme CSS, or null if unavailable.
*/
public function getCssUrl(): ?string {
$compiled = $this->compileCss();
if ($compiled === null) {
return null;
}
return '/themes/' . basename($this->themeDir) . '/theme.css';
}
/**
* Get the public URL for the theme JS, or null if unavailable.
*/
public function getJsUrl(): ?string {
$jsFile = $this->themeDir . '/js/theme.js';
if (!is_file($jsFile)) {
return null;
}
return '/themes/' . basename($this->themeDir) . '/js/theme.js';
}
}