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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user