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
+140 -1
View File
@@ -105,6 +105,10 @@ switch ($route) {
handlePluginToggle($auth, $appConfig);
break;
case 'plugins-toggle-visibility':
handlePluginToggleVisibility($auth, $appConfig);
break;
case 'plugins-delete':
handlePluginDelete($auth, $appConfig);
break;
@@ -167,7 +171,7 @@ function handleDashboard(AdminAuth $auth, array $config): void
$stats = [
'pages' => countFiles($contentDir, ['md', 'php', 'html']),
'directories' => countDirs($contentDir),
'plugins' => countDirs($pluginsDir),
'plugins' => countEnabledPlugins($pluginsDir, $configJson),
'config_exists' => file_exists($configJson),
'content_size' => formatSize(dirSize($contentDir)),
'php_version' => PHP_VERSION,
@@ -176,6 +180,25 @@ function handleDashboard(AdminAuth $auth, array $config): void
// Load site config
$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';
}
@@ -249,6 +272,18 @@ function handleContent(AdminAuth $auth, array $config): void
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
{
$user = $auth->getCurrentUser();
@@ -278,6 +313,7 @@ function handleContentEdit(AdminAuth $auth, array $config): void
} else {
// Handle rename
$newFilename = trim($_POST['filename'] ?? '');
$wasRenamed = false;
if (!empty($newFilename)) {
$newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename);
$newFilename .= '.' . $fileExt;
@@ -288,6 +324,8 @@ function handleContentEdit(AdminAuth $auth, array $config): void
if ($realParentDir && strpos($realParentDir, $realContentDir) === 0) {
if ($newFilePath !== $filePath && !file_exists($newFilePath)) {
rename($filePath, $newFilePath);
$wasRenamed = true;
adminLog($config, 'info', $user['username'] . ' hernoemde ' . basename($filePath) . ' naar ' . $newFilename);
$filePath = $newFilePath;
$newFile = dirname($file) . '/' . $newFilename;
$file = ltrim($newFile, './');
@@ -307,6 +345,9 @@ function handleContentEdit(AdminAuth $auth, array $config): void
: '';
$content = updateContentFrontmatter($content, 'plugins', $plugins);
file_put_contents($filePath, $content);
if (!$wasRenamed) {
adminLog($config, 'info', $user['username'] . ' sloeg ' . basename($filePath) . ' op');
}
}
$message = 'Bestand opgeslagen.';
@@ -346,6 +387,10 @@ function handleContentEdit(AdminAuth $auth, array $config): void
? 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';
}
@@ -405,6 +450,7 @@ function handleContentNew(AdminAuth $auth, array $config): void
}
file_put_contents($filePath, $content);
adminLog($config, 'info', $user['username'] . ' maakte ' . $filename . ' aan in ' . $dir);
header('Location: /admin/content?dir=' . urlencode($dir));
exit;
}
@@ -449,6 +495,8 @@ function handleContentDelete(AdminAuth $auth, array $config): void
&& strpos($realPath, $realContentDir) === 0
) {
if (is_file($filePath)) {
$user = $auth->getCurrentUser();
adminLog($config, 'info', $user['username'] . ' verwijderde ' . basename($filePath));
unlink($filePath);
}
}
@@ -622,6 +670,7 @@ function handleConfig(AdminAuth $auth, array $config): void
$messageType = 'danger';
} else {
file_put_contents($configJson, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' wijzigde site configuratie');
$message = 'Configuratie opgeslagen.';
$messageType = 'success';
$jsonContent = null; // reload from file
@@ -791,6 +840,15 @@ function handlePlugins(AdminAuth $auth, array $config): void
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$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 = [];
if (is_dir($pluginsDir)) {
@@ -804,9 +862,19 @@ function handlePlugins(AdminAuth $auth, array $config): void
$hasMainFile = file_exists($pluginPath . '/' . $item . '.php');
$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[] = [
'name' => $item,
'path' => $pluginPath,
'enabled' => $isEnabled,
'viewable' => $pluginConfig['viewable'] ?? true,
'config' => $pluginConfig,
'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';
require __DIR__ . '/../admin/templates/layout.php';
}
@@ -981,6 +1055,45 @@ function handlePluginToggle(AdminAuth $auth, array $config): void
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'] ?? '')) {
$configFile = $pluginPath . '/config.json';
if (file_exists($configFile)) {
@@ -990,6 +1103,7 @@ function handlePluginToggle(AdminAuth $auth, array $config): void
} else {
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');
@@ -1019,6 +1133,7 @@ function handlePluginDelete(AdminAuth $auth, array $config): void
$file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath());
}
rmdir($pluginPath);
adminLog($config, 'info', ($_SESSION['admin_user'] ?? 'unknown') . ' verwijderde plugin ' . $pluginName);
}
header('Location: /admin/plugins');
@@ -1193,6 +1308,22 @@ function handleUsers(AdminAuth $auth, array $config): void
);
$message = $result['message'];
$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;
}
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
{
$size = 0;