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
+51 -81
View File
@@ -46,7 +46,8 @@ class CodePressCMS {
$this->translations = $this->loadTranslations($this->currentLanguage);
// 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);
$this->pluginManager->setAPI($api);
@@ -207,6 +208,7 @@ class CodePressCMS {
*/
private function buildMenu() {
$this->menu = $this->scanDirectory($this->config['content_dir'], '');
$this->pluginManager->doAction('onMenuBuild', $this->menu);
}
/**
@@ -274,6 +276,7 @@ class CodePressCMS {
private function performSearch($query) {
$this->searchResults = [];
$this->searchInDirectory($this->config['content_dir'], '', $query);
$this->pluginManager->doAction('onSearch', $query, $this->searchResults);
}
/**
@@ -437,7 +440,9 @@ class CodePressCMS {
}
$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']);
return [
@@ -615,32 +620,32 @@ class CodePressCMS {
* @return string Content with auto-linked page titles
*/
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
$pages = $this->getAllPageTitles();
foreach ($pages as $pagePath => $pageTitle) {
// Create a pattern that matches the exact page title (case-insensitive)
// Use word boundaries and avoid H1 tags to prevent linking inside headings
$pattern = '/\b' . preg_quote($pageTitle, '/') . '\b(?!(?=<\/h1>))/i';
if (strtolower($pageTitle) === strtolower($excludeTitle)) {
continue;
}
// 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;
@@ -702,39 +707,29 @@ class CodePressCMS {
* @return string Formatted display name
*/
private function formatDisplayName($filename) {
// Preserve leading dash before processing
$hasLeadingDash = $filename[0] === '-';
if ($hasLeadingDash) {
$filename = substr($filename, 1);
}
// Remove language prefixes dynamically based on available languages
$availableLangs = array_keys($this->getAvailableLanguages());
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
if (preg_match($langPattern, $filename, $matches)) {
$filename = $matches[2];
}
// Remove file extensions (.md, .php, .html) from display names
$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 = trim($name);
$name = ucwords(strtolower($name));
// Post-process special cases in compound names
foreach ($specialCases as $lower => $correct) {
$name = str_ireplace(ucfirst($lower), $correct, $name);
$specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT'];
$lower = strtolower($name);
if (isset($specialCases[$lower])) {
$name = $specialCases[$lower];
} else {
$name = str_ireplace(array_keys($specialCases), array_values($specialCases), $name);
}
return ($hasLeadingDash ? '- ' : '') . $name;
@@ -869,57 +864,24 @@ class CodePressCMS {
*
* @return array Guide page data
*/
private function getGuidePage() {
private function getGuidePage() {
$lang = $this->currentLanguage;
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
if (!file_exists($guideFile)) {
$guideFile = __DIR__ . '/../../../guide/en.codepress.md'; // Fallback to English
}
}
$content = file_get_contents($guideFile);
// Parse metadata first
$parsed = $this->parseMetadata($content);
$metadata = $parsed['metadata'];
$contentWithoutMeta = $parsed['content'];
// Reuse parseMarkdown to avoid duplicating CommonMark setup
$result = $this->parseMarkdown($content, $guideFile);
// Configure CommonMark environment (autoloader already loaded in bootstrap)
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
'max_nesting_level' => 100,
];
// Override title for guide
$result['title'] = $this->t('manual') . ' - CodePress CMS';
$result['layout'] = $result['metadata']['layout'] ?? 'content';
// Create environment with extensions
$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'
];
return $result;
}
/**
@@ -1046,6 +1008,9 @@ private function getGuidePage() {
*/
public function render() {
$page = $this->getPage();
$this->pluginManager->doAction('onPageLoad', $page);
$this->pluginManager->doAction('onBeforeRender');
$menu = $this->getMenu();
$breadcrumb = $this->generateBreadcrumb();
@@ -1065,9 +1030,9 @@ private function getGuidePage() {
// Prepare template data
$templateData = [
'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,
'layout' => $layout,
'page_metadata' => $page['metadata'] ?? [],
@@ -1101,8 +1066,11 @@ private function getGuidePage() {
// Language
'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage),
'current_page' => $_GET['page'] ?? $this->config['default_page'],
'available_langs' => array_map(function($lang) {
$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;
}, $this->getAvailableLanguages()),
// Translations
@@ -1180,6 +1148,8 @@ private function getGuidePage() {
$renderedLayout = SimpleTemplate::render($finalTemplate, $templateData);
echo $renderedLayout;
$this->pluginManager->doAction('onAfterRender', $renderedLayout);
}
/**
+70 -3
View File
@@ -5,10 +5,14 @@ class PluginManager
private array $plugins = [];
private string $pluginsPath;
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->enabledPlugins = $enabledPlugins;
$this->loadPlugins();
}
@@ -33,6 +37,11 @@ class PluginManager
foreach ($pluginDirs as $pluginDir) {
$pluginName = basename($pluginDir);
if (!in_array($pluginName, $this->enabledPlugins, true)) {
continue;
}
$pluginFile = $pluginDir . '/' . $pluginName . '.php';
if (file_exists($pluginFile)) {
@@ -45,11 +54,60 @@ class PluginManager
if ($this->api && method_exists($this->plugins[$pluginName], 'setAPI')) {
$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
{
return $this->plugins[$name] ?? null;
@@ -60,6 +118,16 @@ class PluginManager
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
{
if (method_exists($plugin, 'getConfig')) {
@@ -78,7 +146,6 @@ class PluginManager
continue;
}
// Filter by allowed plugins for this page
if ($allowedPlugins !== null && !in_array($pluginName, $allowedPlugins, true)) {
continue;
}
@@ -107,4 +174,4 @@ class PluginManager
return $sidebarContent;
}
}
}
+2 -2
View File
@@ -27,7 +27,7 @@
<ul class="dropdown-menu dropdown-menu-end" role="menu">
{{#available_langs}}
<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}}
</a>
</li>
@@ -47,7 +47,7 @@
<ul class="dropdown-menu dropdown-menu-end" role="menu">
{{#available_langs}}
<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}}
</a>
</li>