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 d453b8073f
commit 6333bc410f
833 changed files with 108974 additions and 1386 deletions
+230 -150
View File
@@ -26,6 +26,13 @@ require_once __DIR__ . '/../cms/core/class/BotGuard.php';
require_once __DIR__ . '/../cms/core/class/Cache.php';
require_once __DIR__ . '/../cms/core/class/GeoIP.php';
require_once __DIR__ . '/../cms/core/class/Analytics.php';
require_once __DIR__ . '/../cms/core/class/LogManager.php';
// Initialize dynamic logging from site config
$siteConfigForLogging = file_exists($appConfig['config_json'])
? (json_decode(file_get_contents($appConfig['config_json']), true) ?? [])
: [];
LogManager::init($siteConfigForLogging['logging'] ?? []);
$auth = new AdminAuth($appConfig);
@@ -108,6 +115,14 @@ switch ($route) {
handleTheme($auth, $appConfig);
break;
case 'theme-new':
handleThemeNew($auth, $appConfig);
break;
case 'theme-delete':
handleThemeDelete($auth, $appConfig);
break;
case 'plugins':
handlePlugins($auth, $appConfig);
break;
@@ -328,6 +343,11 @@ function adminLog(array $config, string $level, string $message): void
$timestamp = date('Y-m-d H:i:s');
$ip = RequestLogger::getClientIp();
@file_put_contents($logFile, "[{$timestamp}] [{$level}] [{$ip}] {$message}\n", FILE_APPEND);
// Also record through the dynamic log manager (admin event)
if (class_exists('LogManager')) {
LogManager::log(LogManager::EVENT_ADMIN, $level, $message, ['ip' => $ip]);
}
}
function handleContentEdit(AdminAuth $auth, array $config): void
@@ -410,6 +430,18 @@ function handleContentEdit(AdminAuth $auth, array $config): void
$currentLayout = 'sidebar-content';
}
// Load available layouts from the active theme's theme.json
$themeLayouts = [];
$configJson = $config['config_json'];
$configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
if (!is_array($configData)) $configData = [];
$activeThemeName = $configData['active_theme'] ?? 'default';
$activeThemeFile = $config['codepress_root'] . '/themes/' . $activeThemeName . '/theme.json';
if (file_exists($activeThemeFile)) {
$themeJson = json_decode(file_get_contents($activeThemeFile), true) ?? [];
$themeLayouts = $themeJson['template'] ?? [];
}
$fileName = basename($filePath);
$route = 'content-edit';
@@ -522,6 +554,18 @@ function handleContentNew(AdminAuth $auth, array $config): void
}
}
// Load available layouts from the active theme's .twig files
$themeLayouts = [];
$configJson = $config['config_json'];
$configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
if (!is_array($configData)) $configData = [];
$activeThemeName = $configData['active_theme'] ?? 'default';
$activeThemeFile = $config['codepress_root'] . '/themes/' . $activeThemeName . '/theme.json';
if (file_exists($activeThemeFile)) {
$themeJson = json_decode(file_get_contents($activeThemeFile), true) ?? [];
$themeLayouts = $themeJson['template'] ?? [];
}
$route = 'content-new';
require __DIR__ . '/../admin/templates/layout.php';
}
@@ -748,7 +792,7 @@ function handleConfig(AdminAuth $auth, array $config): void
$configData['seo']['description'] = trim($_POST['seo_description'] ?? '');
$configData['seo']['keywords'] = trim($_POST['seo_keywords'] ?? '');
$configData['author']['name'] = trim($_POST['author_name'] ?? '');
$configData['author']['website'] = trim($_POST['author_website'] ?? '');
$configData['author']['website'] = stripUrlScheme(trim($_POST['author_website'] ?? ''));
$configData['show_version'] = !empty($_POST['show_version']);
$configData['features']['auto_link_pages'] = !empty($_POST['feature_auto_link']);
$configData['features']['search_enabled'] = !empty($_POST['feature_search']);
@@ -767,6 +811,20 @@ function handleConfig(AdminAuth $auth, array $config): void
};
$configData['analytics']['excluded_ips'] = $parseLines($_POST['excluded_ips'] ?? '');
// Logging settings
$configData['logging']['enabled'] = !empty($_POST['logging_enabled']);
$configData['logging']['driver'] = ($_POST['logging_driver'] ?? 'sqlite') === 'syslog' ? 'syslog' : 'sqlite';
$configData['logging']['syslog_host'] = trim($_POST['syslog_host'] ?? '');
$configData['logging']['syslog_port'] = max(1, min(65535, (int)($_POST['syslog_port'] ?? 514)));
$configData['logging']['syslog_facility'] = preg_replace('/[^a-z0-9]/', '', $_POST['syslog_facility'] ?? 'local0');
$configData['logging']['syslog_ident'] = trim($_POST['syslog_ident'] ?? 'codepress');
$selectedEvents = $_POST['logging_events'] ?? [];
$allEvents = ['admin', 'requests', 'errors', 'security', 'content', 'system'];
$configData['logging']['events'] = [];
foreach ($allEvents as $ev) {
$configData['logging']['events'][$ev] = in_array($ev, $selectedEvents, true);
}
file_put_contents($configJson, json_encode($configData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' wijzigde site configuratie');
$message = 'Configuratie opgeslagen.';
@@ -993,115 +1051,19 @@ function handleTheme(AdminAuth $auth, array $config): void
$csrf = $auth->getCsrfToken();
$configJson = $config['config_json'];
$themesDir = $config['codepress_root'] . '/themes';
$publicThemes = $config['codepress_root'] . '/public/themes';
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$action = $_POST['action'] ?? '';
if ($action === 'save') {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
$themeFile = $themesDir . '/' . $themeName . '/theme.json';
if (file_exists($themeFile)) {
$themeData = json_decode(file_get_contents($themeFile), true) ?? [];
foreach (['header_color', 'header_font_color', 'navigation_color', 'navigation_font_color', 'sidebar_background', 'sidebar_border'] as $field) {
$value = trim($_POST[$field] ?? '');
if (preg_match('/^#[0-9a-fA-F]{6}$/', $value)) {
$themeData[$field] = $value;
}
}
$themeData['name'] = trim($_POST['theme_name'] ?? $themeData['name'] ?? $themeName);
$themeData['header_height'] = preg_match('/^\d+$/', $_POST['header_height'] ?? '') ? trim($_POST['header_height']) : ($themeData['header_height'] ?? '56');
$themeData['nav_height'] = preg_match('/^\d+$/', $_POST['nav_height'] ?? '') ? trim($_POST['nav_height']) : ($themeData['nav_height'] ?? '42');
$themeData['background_image_opacity'] = preg_match('/^\d+$/', $_POST['background_image_opacity'] ?? '') ? max(0, min(100, intval($_POST['background_image_opacity']))) : ($themeData['background_image_opacity'] ?? '100');
// Handle background image upload
if (!empty($_FILES['bg_image']['name']) && $_FILES['bg_image']['error'] === UPLOAD_ERR_OK) {
$ext = strtolower(pathinfo($_FILES['bg_image']['name'], PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'])) {
$imgName = $themeName . '_bg.' . $ext;
if (!is_dir($publicThemes)) mkdir($publicThemes, 0755, true);
move_uploaded_file($_FILES['bg_image']['tmp_name'], $publicThemes . '/' . $imgName);
$themeData['background_image'] = $imgName;
}
}
// Handle background image URL
$bgUrl = trim($_POST['background_image_url'] ?? '');
if (!empty($bgUrl)) {
$themeData['background_image'] = $bgUrl;
}
// Handle background image removal
if (!empty($_POST['bg_image_remove'])) {
if ($themeData['background_image'] ?? '') {
$oldFile = $publicThemes . '/' . $themeData['background_image'];
if (file_exists($oldFile)) unlink($oldFile);
}
$themeData['background_image'] = '';
}
file_put_contents($themeFile, json_encode($themeData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Thema opgeslagen.';
$messageType = 'success';
}
} elseif ($action === 'activate') {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
if (file_exists($themesDir . '/' . $themeName . '/theme.json')) {
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
$cfg['active_theme'] = $themeName;
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Thema geactiveerd.';
$messageType = 'success';
}
} elseif ($action === 'create') {
$newName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['new_name'] ?? '');
if (empty($newName)) {
$message = 'Geef een naam voor het nieuwe thema.';
$messageType = 'danger';
} elseif (file_exists($themesDir . '/' . $newName . '/theme.json')) {
$message = 'Thema bestaat al.';
$messageType = 'danger';
} else {
$defaults = [
'name' => $newName,
'header_color' => '#0a369d',
'header_font_color' => '#ffffff',
'header_height' => '56',
'navigation_color' => '#2754b4',
'navigation_font_color' => '#ffffff',
'nav_height' => '42',
'sidebar_background' => '#f8f9fa',
'sidebar_border' => '#dee2e6',
'background_image' => '',
'background_image_opacity' => '100',
];
$newThemeDir = $themesDir . '/' . $newName;
mkdir($newThemeDir, 0755, true);
file_put_contents($newThemeDir . '/theme.json', json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Thema aangemaakt.';
$messageType = 'success';
}
} elseif ($action === 'delete') {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
$themeDir = $themesDir . '/' . $themeName;
if (is_dir($themeDir) && $themeName !== 'default') {
$themeFile = $themeDir . '/theme.json';
if (file_exists($themeFile)) unlink($themeFile);
// Remove theme directory if empty
$remaining = array_diff(scandir($themeDir), ['.', '..']);
if (empty($remaining)) rmdir($themeDir);
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
if (($cfg['active_theme'] ?? '') === $themeName) {
$cfg['active_theme'] = 'default';
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
$message = 'Thema verwijderd.';
$messageType = 'success';
}
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
if ($themeName !== '' && file_exists($themesDir . '/' . $themeName . '/theme.json')) {
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
$cfg['active_theme'] = $themeName;
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' activeerde thema ' . $themeName);
$message = 'Thema geactiveerd.';
$messageType = 'success';
}
}
}
@@ -1123,18 +1085,135 @@ function handleTheme(AdminAuth $auth, array $config): void
}
}
// Load theme being edited
$editThemeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['edit'] ?? '');
$editTheme = null;
if ($editThemeName && isset($themes[$editThemeName])) {
$editTheme = $themes[$editThemeName];
$editTheme['name'] = $editThemeName;
}
$route = 'theme';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleThemeNew(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$themesDir = $config['codepress_root'] . '/themes';
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$newName = trim($_POST['theme_name'] ?? '');
if (empty($newName)) {
$message = 'Thema naam is verplicht.';
$messageType = 'danger';
} elseif (!preg_match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $newName)) {
$message = 'Ongeldige thema naam. Gebruik alleen letters, cijfers, streepjes en underscores. Begin met een letter.';
$messageType = 'danger';
} elseif (file_exists($themesDir . '/' . $newName . '/theme.json')) {
$message = 'Thema met deze naam bestaat al.';
$messageType = 'danger';
} else {
$newThemeDir = $themesDir . '/' . $newName;
mkdir($newThemeDir, 0755, true);
$defaults = [
'title' => $newName,
'config' => [
'default_template' => 'full_content',
],
'template' => [
'full_content' => 'full_content.twig',
'left_sidebar' => 'left_sidebar.twig',
'right_sidebar' => 'right_sidebar.twig',
'custom1' => 'custom1.twig',
],
];
file_put_contents($newThemeDir . '/theme.json', json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Scaffold a full theme folder from the default theme
$defaultThemeDir = $themesDir . '/default';
if (is_dir($defaultThemeDir)) {
$copyDirs = ['partials', 'css', 'js'];
foreach ($copyDirs as $sub) {
$src = $defaultThemeDir . '/' . $sub;
if (is_dir($src)) {
$dst = $newThemeDir . '/' . $sub;
mkdir($dst, 0755, true);
foreach (scandir($src) as $f) {
if ($f[0] === '.') continue;
if (is_file($src . '/' . $f)) {
copy($src . '/' . $f, $dst . '/' . $f);
}
}
}
}
foreach (['base.twig', 'full_content.twig', 'left_sidebar.twig', 'right_sidebar.twig', 'custom1.twig'] as $tpl) {
if (is_file($defaultThemeDir . '/' . $tpl)) {
copy($defaultThemeDir . '/' . $tpl, $newThemeDir . '/' . $tpl);
}
}
// Copy the default theme's preview image if present
if (is_file($defaultThemeDir . '/theme.png')) {
copy($defaultThemeDir . '/theme.png', $newThemeDir . '/theme.png');
}
}
adminLog($config, 'info', $user['username'] . ' maakte thema ' . $newName . ' aan');
header('Location: /admin/theme');
exit;
}
}
}
$route = 'theme-new';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleThemeDelete(AdminAuth $auth, array $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /admin/theme');
exit;
}
$configJson = $config['config_json'];
$themesDir = $config['codepress_root'] . '/themes';
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['theme'] ?? '');
$themeDir = $themesDir . '/' . $themeName;
if ($themeName === '' || $themeName === 'default' || !is_dir($themeDir)) {
header('Location: /admin/theme');
exit;
}
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
// Recursively remove the entire theme directory
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($themeDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir()) {
@rmdir($file->getRealPath());
} else {
@unlink($file->getRealPath());
}
}
@rmdir($themeDir);
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
if (($cfg['active_theme'] ?? '') === $themeName) {
$cfg['active_theme'] = 'default';
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
adminLog($config, 'info', ($_SESSION['admin_user'] ?? 'unknown') . ' verwijderde thema ' . $themeName);
}
header('Location: /admin/theme');
exit;
}
function handlePlugins(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
@@ -1707,55 +1786,39 @@ function handleLogs(AdminAuth $auth, array $config): void
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$activeTab = $_GET['tab'] ?? 'admin';
if (!in_array($activeTab, ['admin', 'requests'])) $activeTab = 'admin';
$adminLogFile = $config['log_file'];
$requestLogFile = $config['request_log'];
// Filters
$filterEvent = $_GET['event'] ?? '';
$filterLevel = $_GET['level'] ?? '';
$filterSearch = trim($_GET['search'] ?? '');
$limit = max(10, min(1000, (int)($_GET['limit'] ?? 200)));
// Clear
if (isset($_GET['clear'])) {
$target = $activeTab === 'admin' ? $adminLogFile : $requestLogFile;
@file_put_contents($target, '');
$message = $activeTab === 'admin' ? 'Activiteiten log gewist.' : 'Requestlog gewist.';
header('Location: /admin/logs?tab=' . $activeTab);
LogManager::clear();
$message = 'Log gewist.';
header('Location: /admin/logs');
exit;
}
// Download
if (isset($_GET['download'])) {
$target = $activeTab === 'admin' ? $adminLogFile : $requestLogFile;
$filename = $activeTab === 'admin' ? 'admin.log' : 'requests.log';
if (file_exists($target)) {
header('Content-Type: text/plain');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($target));
readfile($target);
$entries = LogManager::getLogs(1000, $filterEvent ?: null, $filterLevel ?: null, $filterSearch ?: null);
$out = '';
foreach ($entries as $e) {
$out .= '[' . $e['time'] . '] [' . $e['event'] . '] [' . $e['level'] . '] [' . $e['ip'] . '] ' . $e['message'] . "\n";
}
header('Content-Type: text/plain');
header('Content-Disposition: attachment; filename="codepress.log"');
echo $out;
exit;
}
// Read admin log
$adminLogs = [];
if (file_exists($adminLogFile)) {
$lines = file($adminLogFile);
$lines = array_slice($lines, -200);
foreach ($lines as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
$adminLogs[] = [
'time' => $m[1],
'level' => strtolower($m[2]),
'ip' => $m[3],
'message' => $m[4],
];
}
}
$adminLogs = array_reverse($adminLogs);
}
// Read log entries with filters
$logEntries = LogManager::getLogs($limit, $filterEvent ?: null, $filterLevel ?: null, $filterSearch ?: null);
// Read request log
$requestLogger = new RequestLogger($requestLogFile);
$requestLogs = $requestLogger->getLogs(200);
// Available event types for the filter dropdown
$eventTypes = ['admin', 'requests', 'errors', 'security', 'content', 'system'];
$levelTypes = ['info', 'warning', 'error', 'debug'];
$route = 'logs';
require __DIR__ . '/../admin/templates/layout.php';
@@ -1826,6 +1889,22 @@ function handleUpdate(AdminAuth $auth, array $config): void
// --- Frontmatter helpers ---
/**
* Remove the URL scheme (http://, https://) from a website value so it is
* stored as a bare hostname. The engine adds the scheme back when rendering.
*
* @param string $url Raw URL value
* @return string Hostname without scheme
*/
function stripUrlScheme(string $url): string
{
$url = trim($url);
if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) {
$url = preg_replace('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', '', $url);
}
return rtrim($url, '/');
}
/**
* Keep a timestamped copy of a content file before it is overwritten.
*
@@ -1840,6 +1919,7 @@ function backupContentFile(string $filePath, int $keep = 5): void
$contentRoot = realpath(dirname(__DIR__) . '/content');
$realFile = realpath($filePath);
if (!$contentRoot || !$realFile || strpos($realFile, $contentRoot) !== 0) {
return;
}