Fix plugin security, hooks system, and admin features

- Add plugin allowlist (enabled_plugins in config.json)
- Add enable/disable toggle in admin (separate from visibility)
- Add plugin hooks system (actions + filters with auto-registration)
- Fix autoLinkPageTitles nested <a> tag vulnerability
- Move MQTT credentials to environment variables
- Preserve current page in language switcher
- Fix ctime/birthtime for file creation date
- Deduplicate getGuidePage() CommonMark setup
- Simplify formatDisplayName() logic
- Add admin activity log to dashboard
- Add own password change with current password verification
- Apply theme header_color to admin sidebar
- Add content preview button in editor
This commit is contained in:
2026-07-21 13:42:32 +02:00
parent e19433a389
commit c0dc707a51
12 changed files with 412 additions and 116 deletions
+23
View File
@@ -211,6 +211,29 @@ class AdminAuth
return ['success' => false, 'message' => 'Gebruiker niet gevonden.']; return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
} }
public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array
{
$user = $this->findUser($username);
if (!$user) {
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
}
if (!password_verify($currentPassword, $user['password_hash'])) {
return ['success' => false, 'message' => 'Huidig wachtwoord is onjuist.'];
}
if (strlen($newPassword) < 8) {
return ['success' => false, 'message' => 'Nieuw wachtwoord moet minimaal 8 tekens zijn.'];
}
foreach ($this->adminConfig['users'] as &$u) {
if ($u['username'] === $username) {
$u['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
$this->saveAdminConfig();
$this->log('info', "Eigen wachtwoord gewijzigd: {$username}");
return ['success' => true, 'message' => 'Wachtwoord gewijzigd.'];
}
}
return ['success' => false, 'message' => 'Fout bij wijzigen wachtwoord.'];
}
// --- Private helpers --- // --- Private helpers ---
private function findUser(string $username): ?array private function findUser(string $username): ?array
+11 -2
View File
@@ -1,4 +1,13 @@
<!DOCTYPE html> <?php
// Load theme sidebar color from site config
$layoutConfigFile = __DIR__ . '/../../config.json';
$layoutSiteConfig = file_exists($layoutConfigFile) ? json_decode(file_get_contents($layoutConfigFile), true) : [];
$layoutActiveTheme = $layoutSiteConfig['active_theme'] ?? 'default';
$layoutThemeDir = dirname(__DIR__, 1) . '/../themes/' . $layoutActiveTheme;
$layoutThemeFile = $layoutThemeDir . '/theme.json';
$layoutThemeConfig = file_exists($layoutThemeFile) ? json_decode(file_get_contents($layoutThemeFile), true) : [];
$layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
?><!DOCTYPE html>
<html lang="nl"> <html lang="nl">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -8,7 +17,7 @@
<link rel="stylesheet" href="/assets/css/bootstrap-icons.css"> <link rel="stylesheet" href="/assets/css/bootstrap-icons.css">
<style> <style>
body { background-color: #f5f6fa; min-height: 100vh; } body { background-color: #f5f6fa; min-height: 100vh; }
.admin-sidebar { background-color: #0a369d; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; } .admin-sidebar { background-color: <?= htmlspecialchars($layoutSidebarColor) ?>; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; }
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; } .admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); } .admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; } .admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
+5
View File
@@ -48,6 +48,11 @@
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Opslaan <i class="bi bi-check-lg"></i> Opslaan
</button> </button>
<?php if ($isEditable): ?>
<a href="/<?= $currentLang ?>/<?= htmlspecialchars(pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME)) ?>" target="_blank" class="btn btn-outline-info" title="Open in nieuw tabblad">
<i class="bi bi-eye"></i> Preview
</a>
<?php endif; ?>
<a href="/admin/content?dir=<?= urlencode(dirname($file)) ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a> <a href="/admin/content?dir=<?= urlencode(dirname($file)) ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a>
</div> </div>
</form> </form>
+20
View File
@@ -62,6 +62,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-activity"></i> Recente activiteit</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
<?php if (empty($recentLogs)): ?>
<p class="text-muted mb-0">Geen activiteit geregistreerd.</p>
<?php else: ?>
<ul class="list-unstyled mb-0">
<?php foreach ($recentLogs as $log): ?>
<li class="mb-2 pb-2 border-bottom small">
<span class="text-muted"><?= htmlspecialchars($log['time']) ?></span>
<span class="badge bg-<?= $log['level'] === 'warning' ? 'warning text-dark' : ($log['level'] === 'error' ? 'danger' : 'info') ?> me-1"><?= htmlspecialchars($log['level']) ?></span>
<?= htmlspecialchars($log['message']) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header"><i class="bi bi-lightning"></i> Snelle acties</div> <div class="card-header"><i class="bi bi-lightning"></i> Snelle acties</div>
+16 -3
View File
@@ -83,12 +83,19 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-plug"></i> <?= htmlspecialchars($plugin['name']) ?></strong> <strong><i class="bi bi-plug"></i> <?= htmlspecialchars($plugin['name']) ?></strong>
<div>
<?php if ($plugin['enabled']): ?>
<span class="badge bg-success me-1">Actief</span>
<?php else: ?>
<span class="badge bg-secondary me-1">Uitgeschakeld</span>
<?php endif; ?>
<?php if ($plugin['viewable']): ?> <?php if ($plugin['viewable']): ?>
<span class="badge bg-success">Zichtbaar</span> <span class="badge bg-info">Zichtbaar</span>
<?php else: ?> <?php else: ?>
<span class="badge bg-secondary">Systeem</span> <span class="badge bg-secondary">Systeem</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<tr> <tr>
@@ -119,8 +126,14 @@
<?php endif; ?> <?php endif; ?>
<form method="POST" action="/admin/plugins-toggle?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline"> <form method="POST" action="/admin/plugins-toggle?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>"> <input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<button type="submit" class="btn btn-sm <?= $plugin['viewable'] ? 'btn-outline-warning' : 'btn-outline-success' ?>" title="<?= $plugin['viewable'] ? 'Verbergen' : 'Tonen' ?>"> <button type="submit" class="btn btn-sm <?= $plugin['enabled'] ? 'btn-outline-warning' : 'btn-outline-success' ?>" title="<?= $plugin['enabled'] ? 'Uitschakelen' : 'Activeren' ?>">
<i class="bi <?= $plugin['viewable'] ? 'bi-eye-slash' : 'bi-eye' ?>"></i> <?= $plugin['viewable'] ? 'Verberg' : 'Toon' ?> <i class="bi <?= $plugin['enabled'] ? 'bi-pause-circle' : 'bi-play-circle' ?>"></i> <?= $plugin['enabled'] ? 'Uitschakelen' : 'Activeren' ?>
</button>
</form>
<form method="POST" action="/admin/plugins-toggle-visibility?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<button type="submit" class="btn btn-sm <?= $plugin['viewable'] ? 'btn-outline-secondary' : 'btn-outline-info' ?>" title="<?= $plugin['viewable'] ? 'Verbergen' : 'Tonen' ?>">
<i class="bi <?= $plugin['viewable'] ? 'bi-eye-slash' : 'bi-eye' ?>"></i>
</button> </button>
</form> </form>
<?php if ($plugin['has_config']): ?> <?php if ($plugin['has_config']): ?>
+43 -4
View File
@@ -3,7 +3,7 @@
<div class="row g-4"> <div class="row g-4">
<!-- Users list --> <!-- Users list -->
<div class="col-md-7"> <div class="col-md-7">
<div class="card shadow-sm"> <div class="card shadow-sm mb-4">
<div class="card-header"><i class="bi bi-list"></i> Huidige gebruikers</div> <div class="card-header"><i class="bi bi-list"></i> Huidige gebruikers</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
@@ -12,7 +12,7 @@
<th>Gebruikersnaam</th> <th>Gebruikersnaam</th>
<th>Rol</th> <th>Rol</th>
<th>Aangemaakt</th> <th>Aangemaakt</th>
<th style="width: 160px;">Acties</th> <th style="width: 200px;">Acties</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -28,7 +28,12 @@
<td><span class="badge bg-primary"><?= htmlspecialchars($u['role']) ?></span></td> <td><span class="badge bg-primary"><?= htmlspecialchars($u['role']) ?></span></td>
<td class="text-muted"><?= htmlspecialchars($u['created']) ?></td> <td class="text-muted"><?= htmlspecialchars($u['created']) ?></td>
<td> <td>
<!-- Change password --> <?php if ($u['username'] === $user['username']): ?>
<button type="button" class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#changeOwnPasswordModal" title="Eigen wachtwoord wijzigen">
<i class="bi bi-key"></i> Wachtwoord
</button>
<?php else: ?>
<!-- Change password (admin for other users) -->
<form method="POST" action="/admin/users" class="d-inline"> <form method="POST" action="/admin/users" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>"> <input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="change_password"> <input type="hidden" name="action" value="change_password">
@@ -40,7 +45,6 @@
</button> </button>
</div> </div>
</form> </form>
<?php if ($u['username'] !== $user['username']): ?>
<form method="POST" action="/admin/users" class="d-inline ms-1" onsubmit="return confirm('Weet je zeker dat je deze gebruiker wilt verwijderen?')"> <form method="POST" action="/admin/users" class="d-inline ms-1" onsubmit="return confirm('Weet je zeker dat je deze gebruiker wilt verwijderen?')">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>"> <input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
@@ -57,6 +61,41 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Change own password modal -->
<div class="modal fade" id="changeOwnPasswordModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/users">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="change_own_password">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-key"></i> Eigen wachtwoord wijzigen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="current_password" class="form-label">Huidig wachtwoord</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Nieuw wachtwoord</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
<small class="form-text text-muted">Minimaal 8 tekens.</small>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Bevestig nieuw wachtwoord</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="8">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
<button type="submit" class="btn btn-warning"><i class="bi bi-check-lg"></i> Wachtwoord wijzigen</button>
</div>
</form>
</div>
</div>
</div>
</div> </div>
<!-- Add user form --> <!-- Add user form -->
+49 -79
View File
@@ -46,7 +46,8 @@ class CodePressCMS {
$this->translations = $this->loadTranslations($this->currentLanguage); $this->translations = $this->loadTranslations($this->currentLanguage);
// Initialize plugin manager (files already loaded in cms/core/index.php) // Initialize plugin manager (files already loaded in cms/core/index.php)
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins'); $enabledPlugins = $this->config['enabled_plugins'] ?? [];
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins);
$api = new CMSAPI($this); $api = new CMSAPI($this);
$this->pluginManager->setAPI($api); $this->pluginManager->setAPI($api);
@@ -207,6 +208,7 @@ class CodePressCMS {
*/ */
private function buildMenu() { private function buildMenu() {
$this->menu = $this->scanDirectory($this->config['content_dir'], ''); $this->menu = $this->scanDirectory($this->config['content_dir'], '');
$this->pluginManager->doAction('onMenuBuild', $this->menu);
} }
/** /**
@@ -274,6 +276,7 @@ class CodePressCMS {
private function performSearch($query) { private function performSearch($query) {
$this->searchResults = []; $this->searchResults = [];
$this->searchInDirectory($this->config['content_dir'], '', $query); $this->searchInDirectory($this->config['content_dir'], '', $query);
$this->pluginManager->doAction('onSearch', $query, $this->searchResults);
} }
/** /**
@@ -437,7 +440,9 @@ class CodePressCMS {
} }
$stats = stat($filePath); $stats = stat($filePath);
$created = date('d-m-Y H:i', $stats['ctime']); // Use birthtime if available (macOS/BSD), fall back to mtime on Linux where ctime is inode change time
$createdTimestamp = $stats['birthtime'] ?? $stats['mtime'];
$created = date('d-m-Y H:i', $createdTimestamp);
$modified = date('d-m-Y H:i', $stats['mtime']); $modified = date('d-m-Y H:i', $stats['mtime']);
return [ return [
@@ -615,32 +620,32 @@ class CodePressCMS {
* @return string Content with auto-linked page titles * @return string Content with auto-linked page titles
*/ */
private function autoLinkPageTitles($content, $excludeTitle = '') { private function autoLinkPageTitles($content, $excludeTitle = '') {
// Protect existing <a> tags and <h1> content with placeholders
$placeholders = [];
$content = preg_replace_callback('/<a\b[^>]*>.*?<\/a>|<h1\b[^>]*>.*?<\/h1>|\[([^\]]*)\]\(([^)]*)\)|^#{1,6}\s.*$/m', function($m) use (&$placeholders) {
$key = '@@LINK_' . count($placeholders) . '@@';
$placeholders[$key] = $m[0];
return $key;
}, $content);
// Get all available pages with their titles // Get all available pages with their titles
$pages = $this->getAllPageTitles(); $pages = $this->getAllPageTitles();
foreach ($pages as $pagePath => $pageTitle) { foreach ($pages as $pagePath => $pageTitle) {
// Create a pattern that matches the exact page title (case-insensitive) if (strtolower($pageTitle) === strtolower($excludeTitle)) {
// Use word boundaries and avoid H1 tags to prevent linking inside headings continue;
$pattern = '/\b' . preg_quote($pageTitle, '/') . '\b(?!(?=<\/h1>))/i';
// Replace with link, but avoid linking inside existing links, headings, or markdown
$replacement = function($matches) use ($pageTitle, $pagePath, $excludeTitle) {
$text = $matches[0];
// Check if we're inside an existing link or markdown syntax, or if it's the current page title
if (preg_match('/\[.*?\]\(.*?\)/', $text) ||
preg_match('/\[.*?\]:/', $text) ||
preg_match('/<a[^>]*>/', $text) ||
preg_match('/href=/', $text) ||
preg_match('/<h1>/', $text) ||
strtolower($text) === strtolower($excludeTitle)) {
return $text; // Don't link existing links, current page title, or H1 headings
} }
return '<a href="' . $this->buildUrl($pagePath) . '" class="auto-link" title="' . $this->t('go_to') . ' ' . htmlspecialchars($pageTitle) . '">' . $text . '</a>'; $pattern = '/\b' . preg_quote($pageTitle, '/') . '\b/i';
};
$content = preg_replace_callback($pattern, $replacement, $content); $content = preg_replace_callback($pattern, function($matches) use ($pageTitle, $pagePath) {
return '<a href="' . $this->buildUrl($pagePath) . '" class="auto-link" title="' . $this->t('go_to') . ' ' . htmlspecialchars($pageTitle) . '">' . $matches[0] . '</a>';
}, $content);
}
// Restore protected placeholders
foreach ($placeholders as $key => $value) {
$content = str_replace($key, $value, $content);
} }
return $content; return $content;
@@ -702,39 +707,29 @@ class CodePressCMS {
* @return string Formatted display name * @return string Formatted display name
*/ */
private function formatDisplayName($filename) { private function formatDisplayName($filename) {
// Preserve leading dash before processing
$hasLeadingDash = $filename[0] === '-'; $hasLeadingDash = $filename[0] === '-';
if ($hasLeadingDash) { if ($hasLeadingDash) {
$filename = substr($filename, 1); $filename = substr($filename, 1);
} }
// Remove language prefixes dynamically based on available languages
$availableLangs = array_keys($this->getAvailableLanguages()); $availableLangs = array_keys($this->getAvailableLanguages());
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/'; $langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
if (preg_match($langPattern, $filename, $matches)) { if (preg_match($langPattern, $filename, $matches)) {
$filename = $matches[2]; $filename = $matches[2];
} }
// Remove file extensions (.md, .php, .html) from display names
$filename = preg_replace('/\.(md|php|html)$/', '', $filename); $filename = preg_replace('/\.(md|php|html)$/', '', $filename);
// Handle special cases (case-sensitive display names)
$specialCases = [
'phpinfo' => 'phpinfo',
'ict' => 'ICT',
];
if (isset($specialCases[strtolower($filename)])) {
return ($hasLeadingDash ? '- ' : '') . $specialCases[strtolower($filename)];
}
// Replace hyphens and underscores with spaces, then title case
$name = str_replace(['-', '_'], ' ', $filename); $name = str_replace(['-', '_'], ' ', $filename);
$name = trim($name); $name = trim($name);
$name = ucwords(strtolower($name)); $name = ucwords(strtolower($name));
// Post-process special cases in compound names $specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT'];
foreach ($specialCases as $lower => $correct) { $lower = strtolower($name);
$name = str_ireplace(ucfirst($lower), $correct, $name); if (isset($specialCases[$lower])) {
$name = $specialCases[$lower];
} else {
$name = str_ireplace(array_keys($specialCases), array_values($specialCases), $name);
} }
return ($hasLeadingDash ? '- ' : '') . $name; return ($hasLeadingDash ? '- ' : '') . $name;
@@ -869,7 +864,7 @@ class CodePressCMS {
* *
* @return array Guide page data * @return array Guide page data
*/ */
private function getGuidePage() { private function getGuidePage() {
$lang = $this->currentLanguage; $lang = $this->currentLanguage;
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md'; $guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
@@ -879,47 +874,14 @@ private function getGuidePage() {
$content = file_get_contents($guideFile); $content = file_get_contents($guideFile);
// Parse metadata first // Reuse parseMarkdown to avoid duplicating CommonMark setup
$parsed = $this->parseMetadata($content); $result = $this->parseMarkdown($content, $guideFile);
$metadata = $parsed['metadata'];
$contentWithoutMeta = $parsed['content'];
// Configure CommonMark environment (autoloader already loaded in bootstrap) // Override title for guide
$config = [ $result['title'] = $this->t('manual') . ' - CodePress CMS';
'html_input' => 'strip', $result['layout'] = $result['metadata']['layout'] ?? 'content';
'allow_unsafe_links' => false,
'max_nesting_level' => 100,
];
// Create environment with extensions return $result;
$environment = new \League\CommonMark\Environment\Environment($config);
$environment->addExtension(new \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension());
$environment->addExtension(new \League\CommonMark\Extension\Autolink\AutolinkExtension());
$environment->addExtension(new \League\CommonMark\Extension\Strikethrough\StrikethroughExtension());
$environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension());
$environment->addExtension(new \League\CommonMark\Extension\TaskList\TaskListExtension());
// Create converter
$converter = new \League\CommonMark\MarkdownConverter($environment);
// Convert to HTML
$body = $converter->convert($contentWithoutMeta)->getContent();
// Extract title from metadata or first H1
$title = $metadata['title'] ?? '';
if (empty($title) && preg_match('/^#\s+(.+)$/m', $contentWithoutMeta, $matches)) {
$title = trim($matches[1]);
}
// Set special title for guide
$title = $this->t('manual') . ' - CodePress CMS';
return [
'title' => $title,
'content' => $body,
'metadata' => $metadata,
'layout' => $metadata['layout'] ?? 'content'
];
} }
/** /**
@@ -1046,6 +1008,9 @@ private function getGuidePage() {
*/ */
public function render() { public function render() {
$page = $this->getPage(); $page = $this->getPage();
$this->pluginManager->doAction('onPageLoad', $page);
$this->pluginManager->doAction('onBeforeRender');
$menu = $this->getMenu(); $menu = $this->getMenu();
$breadcrumb = $this->generateBreadcrumb(); $breadcrumb = $this->generateBreadcrumb();
@@ -1065,9 +1030,9 @@ private function getGuidePage() {
// Prepare template data // Prepare template data
$templateData = [ $templateData = [
'site_title' => $this->config['site_title'], 'site_title' => $this->config['site_title'],
'page_title' => htmlspecialchars($page['title']), 'page_title' => htmlspecialchars($this->pluginManager->applyFilters('onTitleFilter', $page['title'])),
'content' => $this->processContent($page['content']), 'content' => $this->pluginManager->applyFilters('onContentFilter', $this->processContent($page['content'])),
'sidebar_content' => $sidebarContent, 'sidebar_content' => $sidebarContent,
'layout' => $layout, 'layout' => $layout,
'page_metadata' => $page['metadata'] ?? [], 'page_metadata' => $page['metadata'] ?? [],
@@ -1101,8 +1066,11 @@ private function getGuidePage() {
// Language // Language
'current_lang' => $this->currentLanguage, 'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage), 'current_lang_upper' => strtoupper($this->currentLanguage),
'current_page' => $_GET['page'] ?? $this->config['default_page'],
'available_langs' => array_map(function($lang) { 'available_langs' => array_map(function($lang) {
$lang['is_current'] = $lang['code'] === $this->currentLanguage; $lang['is_current'] = $lang['code'] === $this->currentLanguage;
$page = $_GET['page'] ?? $this->config['default_page'];
$lang['url'] = '/' . $lang['code'] . ($page !== $this->config['default_page'] ? '/' . $page : '');
return $lang; return $lang;
}, $this->getAvailableLanguages()), }, $this->getAvailableLanguages()),
// Translations // Translations
@@ -1180,6 +1148,8 @@ private function getGuidePage() {
$renderedLayout = SimpleTemplate::render($finalTemplate, $templateData); $renderedLayout = SimpleTemplate::render($finalTemplate, $templateData);
echo $renderedLayout; echo $renderedLayout;
$this->pluginManager->doAction('onAfterRender', $renderedLayout);
} }
/** /**
+69 -2
View File
@@ -5,10 +5,14 @@ class PluginManager
private array $plugins = []; private array $plugins = [];
private string $pluginsPath; private string $pluginsPath;
private ?CMSAPI $api = null; private ?CMSAPI $api = null;
private array $enabledPlugins = [];
private array $actions = [];
private array $filters = [];
public function __construct(string $pluginsPath) public function __construct(string $pluginsPath, array $enabledPlugins = [])
{ {
$this->pluginsPath = $pluginsPath; $this->pluginsPath = $pluginsPath;
$this->enabledPlugins = $enabledPlugins;
$this->loadPlugins(); $this->loadPlugins();
} }
@@ -33,6 +37,11 @@ class PluginManager
foreach ($pluginDirs as $pluginDir) { foreach ($pluginDirs as $pluginDir) {
$pluginName = basename($pluginDir); $pluginName = basename($pluginDir);
if (!in_array($pluginName, $this->enabledPlugins, true)) {
continue;
}
$pluginFile = $pluginDir . '/' . $pluginName . '.php'; $pluginFile = $pluginDir . '/' . $pluginName . '.php';
if (file_exists($pluginFile)) { if (file_exists($pluginFile)) {
@@ -45,10 +54,59 @@ class PluginManager
if ($this->api && method_exists($this->plugins[$pluginName], 'setAPI')) { if ($this->api && method_exists($this->plugins[$pluginName], 'setAPI')) {
$this->plugins[$pluginName]->setAPI($this->api); $this->plugins[$pluginName]->setAPI($this->api);
} }
// Auto-register hooks from plugin methods
$hookMethods = ['onPageLoad', 'onBeforeRender', 'onAfterRender', 'onSearch', 'onMenuBuild'];
foreach ($hookMethods as $hook) {
if (method_exists($this->plugins[$pluginName], $hook)) {
$this->addAction($hook, [$this->plugins[$pluginName], $hook]);
}
}
// Register filter methods
$filterMethods = ['onContentFilter', 'onTitleFilter', 'onMenuFilter'];
foreach ($filterMethods as $filter) {
if (method_exists($this->plugins[$pluginName], $filter)) {
$this->addFilter($filter, [$this->plugins[$pluginName], $filter]);
} }
} }
} }
} }
}
}
public function addAction(string $hook, callable $callback, int $priority = 10): void
{
$this->actions[$hook][$priority][] = $callback;
}
public function addFilter(string $hook, callable $callback, int $priority = 10): void
{
$this->filters[$hook][$priority][] = $callback;
}
public function doAction(string $hook, ...$args): void
{
if (!isset($this->actions[$hook])) return;
ksort($this->actions[$hook]);
foreach ($this->actions[$hook] as $callbacks) {
foreach ($callbacks as $callback) {
$callback(...$args);
}
}
}
public function applyFilters(string $hook, $value, ...$args)
{
if (!isset($this->filters[$hook])) return $value;
ksort($this->filters[$hook]);
foreach ($this->filters[$hook] as $callbacks) {
foreach ($callbacks as $callback) {
$value = $callback($value, ...$args);
}
}
return $value;
}
public function getPlugin(string $name): ?object public function getPlugin(string $name): ?object
{ {
@@ -60,6 +118,16 @@ class PluginManager
return $this->plugins; return $this->plugins;
} }
public function getEnabledPlugins(): array
{
return $this->enabledPlugins;
}
public function isEnabled(string $pluginName): bool
{
return in_array($pluginName, $this->enabledPlugins, true);
}
public function isPluginViewable(object $plugin): bool public function isPluginViewable(object $plugin): bool
{ {
if (method_exists($plugin, 'getConfig')) { if (method_exists($plugin, 'getConfig')) {
@@ -78,7 +146,6 @@ class PluginManager
continue; continue;
} }
// Filter by allowed plugins for this page
if ($allowedPlugins !== null && !in_array($pluginName, $allowedPlugins, true)) { if ($allowedPlugins !== null && !in_array($pluginName, $allowedPlugins, true)) {
continue; continue;
} }
+2 -2
View File
@@ -27,7 +27,7 @@
<ul class="dropdown-menu dropdown-menu-end" role="menu"> <ul class="dropdown-menu dropdown-menu-end" role="menu">
{{#available_langs}} {{#available_langs}}
<li role="none"> <li role="none">
<a class="dropdown-item {{#is_current}}active{{/is_current}}" href="/{{code}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}"> <a class="dropdown-item {{#is_current}}active{{/is_current}}" href="{{url}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}">
{{native_name}} {{native_name}}
</a> </a>
</li> </li>
@@ -47,7 +47,7 @@
<ul class="dropdown-menu dropdown-menu-end" role="menu"> <ul class="dropdown-menu dropdown-menu-end" role="menu">
{{#available_langs}} {{#available_langs}}
<li role="none"> <li role="none">
<a class="dropdown-item {{#is_current}}active{{/is_current}}" href="/{{code}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}"> <a class="dropdown-item {{#is_current}}active{{/is_current}}" href="{{url}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}">
{{native_name}} {{native_name}}
</a> </a>
</li> </li>
+5
View File
@@ -20,6 +20,11 @@
"website": "https:\/\/noorlander.info" "website": "https:\/\/noorlander.info"
}, },
"show_version": false, "show_version": false,
"enabled_plugins": [
"MQTTTracker",
"HTMLBlock",
"test"
],
"features": { "features": {
"auto_link_pages": true, "auto_link_pages": true,
"search_enabled": true, "search_enabled": true,
+11 -5
View File
@@ -32,11 +32,11 @@ class MQTTTracker
$this->config = [ $this->config = [
'enabled' => true, 'enabled' => true,
'viewable' => false, 'viewable' => false,
'broker_host' => 'localhost', 'broker_host' => getenv('MQTT_BROKER_HOST') ?: 'localhost',
'broker_port' => 1883, 'broker_port' => getenv('MQTT_BROKER_PORT') ?: 1883,
'client_id' => 'codepress_cms', 'client_id' => 'codepress_cms',
'username' => '', 'username' => getenv('MQTT_USERNAME') ?: '',
'password' => '', 'password' => getenv('MQTT_PASSWORD') ?: '',
'topic_prefix' => 'codepress', 'topic_prefix' => 'codepress',
'track_visitors' => true, 'track_visitors' => true,
'track_pages' => true, 'track_pages' => true,
@@ -48,7 +48,13 @@ class MQTTTracker
if (file_exists($configFile)) { if (file_exists($configFile)) {
$jsonConfig = json_decode(file_get_contents($configFile), true); $jsonConfig = json_decode(file_get_contents($configFile), true);
$this->config = array_merge($this->config, $jsonConfig); // Only merge non-sensitive keys from config file
$sensitiveKeys = ['password', 'username'];
foreach ($jsonConfig as $key => $value) {
if (!in_array($key, $sensitiveKeys, true)) {
$this->config[$key] = $value;
}
}
} }
} }
+140 -1
View File
@@ -105,6 +105,10 @@ switch ($route) {
handlePluginToggle($auth, $appConfig); handlePluginToggle($auth, $appConfig);
break; break;
case 'plugins-toggle-visibility':
handlePluginToggleVisibility($auth, $appConfig);
break;
case 'plugins-delete': case 'plugins-delete':
handlePluginDelete($auth, $appConfig); handlePluginDelete($auth, $appConfig);
break; break;
@@ -167,7 +171,7 @@ function handleDashboard(AdminAuth $auth, array $config): void
$stats = [ $stats = [
'pages' => countFiles($contentDir, ['md', 'php', 'html']), 'pages' => countFiles($contentDir, ['md', 'php', 'html']),
'directories' => countDirs($contentDir), 'directories' => countDirs($contentDir),
'plugins' => countDirs($pluginsDir), 'plugins' => countEnabledPlugins($pluginsDir, $configJson),
'config_exists' => file_exists($configJson), 'config_exists' => file_exists($configJson),
'content_size' => formatSize(dirSize($contentDir)), 'content_size' => formatSize(dirSize($contentDir)),
'php_version' => PHP_VERSION, 'php_version' => PHP_VERSION,
@@ -176,6 +180,25 @@ function handleDashboard(AdminAuth $auth, array $config): void
// Load site config // Load site config
$siteConfig = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : []; $siteConfig = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
// Load recent activity log
$logFile = $config['log_file'];
$recentLogs = [];
if (file_exists($logFile)) {
$lines = file($logFile);
$lines = array_slice($lines, -20);
foreach ($lines as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
$recentLogs[] = [
'time' => $m[1],
'level' => strtolower($m[2]),
'ip' => $m[3],
'message' => $m[4],
];
}
}
$recentLogs = array_reverse($recentLogs);
}
require __DIR__ . '/../admin/templates/layout.php'; require __DIR__ . '/../admin/templates/layout.php';
} }
@@ -249,6 +272,18 @@ function handleContent(AdminAuth $auth, array $config): void
require __DIR__ . '/../admin/templates/layout.php'; require __DIR__ . '/../admin/templates/layout.php';
} }
function adminLog(array $config, string $level, string $message): void
{
$logFile = $config['log_file'];
$dir = dirname($logFile);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$timestamp = date('Y-m-d H:i:s');
$ip = $_SERVER['REMOTE_ADDR'] ?? 'cli';
@file_put_contents($logFile, "[{$timestamp}] [{$level}] [{$ip}] {$message}\n", FILE_APPEND);
}
function handleContentEdit(AdminAuth $auth, array $config): void function handleContentEdit(AdminAuth $auth, array $config): void
{ {
$user = $auth->getCurrentUser(); $user = $auth->getCurrentUser();
@@ -278,6 +313,7 @@ function handleContentEdit(AdminAuth $auth, array $config): void
} else { } else {
// Handle rename // Handle rename
$newFilename = trim($_POST['filename'] ?? ''); $newFilename = trim($_POST['filename'] ?? '');
$wasRenamed = false;
if (!empty($newFilename)) { if (!empty($newFilename)) {
$newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename); $newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename);
$newFilename .= '.' . $fileExt; $newFilename .= '.' . $fileExt;
@@ -288,6 +324,8 @@ function handleContentEdit(AdminAuth $auth, array $config): void
if ($realParentDir && strpos($realParentDir, $realContentDir) === 0) { if ($realParentDir && strpos($realParentDir, $realContentDir) === 0) {
if ($newFilePath !== $filePath && !file_exists($newFilePath)) { if ($newFilePath !== $filePath && !file_exists($newFilePath)) {
rename($filePath, $newFilePath); rename($filePath, $newFilePath);
$wasRenamed = true;
adminLog($config, 'info', $user['username'] . ' hernoemde ' . basename($filePath) . ' naar ' . $newFilename);
$filePath = $newFilePath; $filePath = $newFilePath;
$newFile = dirname($file) . '/' . $newFilename; $newFile = dirname($file) . '/' . $newFilename;
$file = ltrim($newFile, './'); $file = ltrim($newFile, './');
@@ -307,6 +345,9 @@ function handleContentEdit(AdminAuth $auth, array $config): void
: ''; : '';
$content = updateContentFrontmatter($content, 'plugins', $plugins); $content = updateContentFrontmatter($content, 'plugins', $plugins);
file_put_contents($filePath, $content); file_put_contents($filePath, $content);
if (!$wasRenamed) {
adminLog($config, 'info', $user['username'] . ' sloeg ' . basename($filePath) . ' op');
}
} }
$message = 'Bestand opgeslagen.'; $message = 'Bestand opgeslagen.';
@@ -346,6 +387,10 @@ function handleContentEdit(AdminAuth $auth, array $config): void
? array_map('trim', explode(',', $currentPlugins)) ? array_map('trim', explode(',', $currentPlugins))
: []; : [];
// Get current language for preview links
$siteConfig = file_exists($config['config_json']) ? json_decode(file_get_contents($config['config_json']), true) : [];
$currentLang = $siteConfig['language']['default'] ?? 'nl';
require __DIR__ . '/../admin/templates/layout.php'; require __DIR__ . '/../admin/templates/layout.php';
} }
@@ -405,6 +450,7 @@ function handleContentNew(AdminAuth $auth, array $config): void
} }
file_put_contents($filePath, $content); file_put_contents($filePath, $content);
adminLog($config, 'info', $user['username'] . ' maakte ' . $filename . ' aan in ' . $dir);
header('Location: /admin/content?dir=' . urlencode($dir)); header('Location: /admin/content?dir=' . urlencode($dir));
exit; exit;
} }
@@ -449,6 +495,8 @@ function handleContentDelete(AdminAuth $auth, array $config): void
&& strpos($realPath, $realContentDir) === 0 && strpos($realPath, $realContentDir) === 0
) { ) {
if (is_file($filePath)) { if (is_file($filePath)) {
$user = $auth->getCurrentUser();
adminLog($config, 'info', $user['username'] . ' verwijderde ' . basename($filePath));
unlink($filePath); unlink($filePath);
} }
} }
@@ -622,6 +670,7 @@ function handleConfig(AdminAuth $auth, array $config): void
$messageType = 'danger'; $messageType = 'danger';
} else { } else {
file_put_contents($configJson, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); file_put_contents($configJson, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' wijzigde site configuratie');
$message = 'Configuratie opgeslagen.'; $message = 'Configuratie opgeslagen.';
$messageType = 'success'; $messageType = 'success';
$jsonContent = null; // reload from file $jsonContent = null; // reload from file
@@ -791,6 +840,15 @@ function handlePlugins(AdminAuth $auth, array $config): void
$user = $auth->getCurrentUser(); $user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken(); $csrf = $auth->getCsrfToken();
$pluginsDir = $config['plugins_dir']; $pluginsDir = $config['plugins_dir'];
// Load enabled_plugins from config.json
$siteConfig = [];
if (file_exists($config['config_json'])) {
$siteConfig = json_decode(file_get_contents($config['config_json']), true) ?? [];
}
$enabledPlugins = $siteConfig['enabled_plugins'] ?? [];
$regenerateConfig = false;
$plugins = []; $plugins = [];
if (is_dir($pluginsDir)) { if (is_dir($pluginsDir)) {
@@ -804,9 +862,19 @@ function handlePlugins(AdminAuth $auth, array $config): void
$hasMainFile = file_exists($pluginPath . '/' . $item . '.php'); $hasMainFile = file_exists($pluginPath . '/' . $item . '.php');
$hasReadme = file_exists($pluginPath . '/README.md'); $hasReadme = file_exists($pluginPath . '/README.md');
$isEnabled = in_array($item, $enabledPlugins, true);
// Auto-register plugin in enabled_plugins if it has a main file and is not in the list
if ($hasMainFile && !$isEnabled) {
$enabledPlugins[] = $item;
$regenerateConfig = true;
$isEnabled = true;
}
$plugins[] = [ $plugins[] = [
'name' => $item, 'name' => $item,
'path' => $pluginPath, 'path' => $pluginPath,
'enabled' => $isEnabled,
'viewable' => $pluginConfig['viewable'] ?? true, 'viewable' => $pluginConfig['viewable'] ?? true,
'config' => $pluginConfig, 'config' => $pluginConfig,
'has_config' => $hasConfig, 'has_config' => $hasConfig,
@@ -816,6 +884,12 @@ function handlePlugins(AdminAuth $auth, array $config): void
} }
} }
// Save updated enabled_plugins back to config.json
if ($regenerateConfig) {
$siteConfig['enabled_plugins'] = $enabledPlugins;
file_put_contents($config['config_json'], json_encode($siteConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
$route = 'plugins'; $route = 'plugins';
require __DIR__ . '/../admin/templates/layout.php'; require __DIR__ . '/../admin/templates/layout.php';
} }
@@ -981,6 +1055,45 @@ function handlePluginToggle(AdminAuth $auth, array $config): void
exit; exit;
} }
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$siteConfig = [];
$configJson = $config['config_json'];
if (file_exists($configJson)) {
$siteConfig = json_decode(file_get_contents($configJson), true) ?? [];
}
$enabledPlugins = $siteConfig['enabled_plugins'] ?? [];
if (in_array($pluginName, $enabledPlugins, true)) {
$enabledPlugins = array_values(array_filter($enabledPlugins, fn($p) => $p !== $pluginName));
} else {
$enabledPlugins[] = $pluginName;
}
$siteConfig['enabled_plugins'] = $enabledPlugins;
file_put_contents($configJson, json_encode($siteConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', ($_SESSION['admin_user'] ?? 'unknown') . ' schakelde plugin ' . $pluginName . ' ' . (in_array($pluginName, $enabledPlugins, true) ? 'uit' : 'in'));
}
header('Location: /admin/plugins');
exit;
}
function handlePluginToggleVisibility(AdminAuth $auth, array $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /admin/plugins');
exit;
}
$pluginsDir = $config['plugins_dir'];
$pluginName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['plugin'] ?? '');
$pluginPath = $pluginsDir . '/' . $pluginName;
if (empty($pluginName) || !is_dir($pluginPath)) {
header('Location: /admin/plugins');
exit;
}
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) { if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$configFile = $pluginPath . '/config.json'; $configFile = $pluginPath . '/config.json';
if (file_exists($configFile)) { if (file_exists($configFile)) {
@@ -990,6 +1103,7 @@ function handlePluginToggle(AdminAuth $auth, array $config): void
} else { } else {
file_put_contents($configFile, json_encode(['viewable' => false], JSON_PRETTY_PRINT)); file_put_contents($configFile, json_encode(['viewable' => false], JSON_PRETTY_PRINT));
} }
adminLog($config, 'info', ($_SESSION['admin_user'] ?? 'unknown') . ' wijzigde zichtbaarheid van plugin ' . $pluginName);
} }
header('Location: /admin/plugins'); header('Location: /admin/plugins');
@@ -1019,6 +1133,7 @@ function handlePluginDelete(AdminAuth $auth, array $config): void
$file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath());
} }
rmdir($pluginPath); rmdir($pluginPath);
adminLog($config, 'info', ($_SESSION['admin_user'] ?? 'unknown') . ' verwijderde plugin ' . $pluginName);
} }
header('Location: /admin/plugins'); header('Location: /admin/plugins');
@@ -1193,6 +1308,22 @@ function handleUsers(AdminAuth $auth, array $config): void
); );
$message = $result['message']; $message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger'; $messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'change_own_password') {
$currentPassword = $_POST['current_password'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
if ($newPassword !== $confirmPassword) {
$message = 'De nieuwe wachtwoorden komen niet overeen.';
$messageType = 'danger';
} else {
$result = $auth->changeOwnPassword(
$user['username'],
$currentPassword,
$newPassword
);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
}
} }
} }
} }
@@ -1266,6 +1397,14 @@ function countDirs(string $dir): int
return $count; return $count;
} }
function countEnabledPlugins(string $pluginsDir, string $configJson): int
{
if (!is_dir($pluginsDir)) return 0;
$siteConfig = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
$enabled = $siteConfig['enabled_plugins'] ?? [];
return count(array_filter($enabled, fn($p) => is_dir($pluginsDir . '/' . $p)));
}
function dirSize(string $dir): int function dirSize(string $dir): int
{ {
$size = 0; $size = 0;