Files
CodePress/public/admin.php
Edwin Noorlander dc0d370e65 Media browser: recursive scan entire content/ tree, /-media/ URL prefix, editor change detection fix
- handleMediaList() now scans content/ recursively for all media files
- URLs use /-media/ prefix mapping directly to content/ (no special cases)
- index.php: added /-media/ route, kept /-assets/ for backward compat
- editor-toolbar.js: fixed editor.on('change') placement (was inside switchMode)
- content-edit.php and content-new.php: back-btn unsaved-changes detection
- Removed unused __editorCleanup global
2026-06-24 17:00:59 +02:00

1339 lines
48 KiB
PHP

<?php
/**
* CodePress Admin Console - Entry Point
* Access via: /admin.php?route=login|dashboard|content|config|plugins|users|logout
*/
// Security headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header_remove('X-Powered-By');
// Load admin components
$appConfig = require __DIR__ . '/../admin/config/app.php';
require_once __DIR__ . '/../admin/src/AdminAuth.php';
$auth = new AdminAuth($appConfig);
// Routing
$route = $_GET['route'] ?? '';
// Public routes (no auth required)
if ($route === 'login') {
handleLogin($auth);
exit;
}
// All other routes require authentication
if (!$auth->isAuthenticated()) {
header('Location: admin.php?route=login');
exit;
}
// Authenticated routes
switch ($route) {
case 'logout':
$auth->logout();
header('Location: admin.php?route=login');
exit;
case 'dashboard':
case '':
handleDashboard($auth, $appConfig);
break;
case 'content':
handleContent($auth, $appConfig);
break;
case 'content-edit':
handleContentEdit($auth, $appConfig);
break;
case 'content-new':
handleContentNew($auth, $appConfig);
break;
case 'content-delete':
handleContentDelete($auth, $appConfig);
break;
case 'content-dir-create':
handleContentDirCreate($auth, $appConfig);
break;
case 'content-dir-rename':
handleContentDirRename($auth, $appConfig);
break;
case 'content-move':
handleContentMove($auth, $appConfig);
break;
case 'content-dir-delete':
handleContentDirDelete($auth, $appConfig);
break;
case 'config':
handleConfig($auth, $appConfig);
break;
case 'theme':
handleTheme($auth, $appConfig);
break;
case 'plugins':
handlePlugins($auth, $appConfig);
break;
case 'plugins-config':
handlePluginConfig($auth, $appConfig);
break;
case 'plugins-edit':
handlePluginEdit($auth, $appConfig);
break;
case 'plugins-new':
handlePluginNew($auth, $appConfig);
break;
case 'plugins-toggle':
handlePluginToggle($auth, $appConfig);
break;
case 'plugins-delete':
handlePluginDelete($auth, $appConfig);
break;
case 'media':
handleMedia($auth, $appConfig);
break;
case 'media-list':
handleMediaList($auth, $appConfig);
break;
case 'users':
handleUsers($auth, $appConfig);
break;
default:
header('Location: admin.php?route=dashboard');
exit;
}
// --- Route Handlers ---
function handleLogin(AdminAuth $auth): void
{
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$result = $auth->login($username, $password);
if ($result['success']) {
header('Location: admin.php?route=dashboard');
exit;
}
$error = $result['message'];
}
require __DIR__ . '/../admin/templates/login.php';
}
function handleDashboard(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
// Gather stats
$contentDir = $config['content_dir'];
$pluginsDir = $config['plugins_dir'];
$configJson = $config['config_json'];
$stats = [
'pages' => countFiles($contentDir, ['md', 'php', 'html']),
'directories' => countDirs($contentDir),
'plugins' => countDirs($pluginsDir),
'config_exists' => file_exists($configJson),
'content_size' => formatSize(dirSize($contentDir)),
'php_version' => PHP_VERSION,
];
// Load site config
$siteConfig = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
require __DIR__ . '/../admin/templates/layout.php';
}
function handleContent(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$contentDir = $config['content_dir'];
$subdir = $_GET['dir'] ?? '';
// Prevent path traversal
$subdir = str_replace(['../', '..\\'], '', $subdir);
$subdir = trim($subdir, '/');
if ($subdir === '.' || $subdir === '') {
$subdir = '';
}
$fullPath = rtrim($contentDir, '/') . '/' . $subdir;
if (!is_dir($fullPath)) {
$fullPath = $contentDir;
$subdir = '';
}
$message = '';
$messageType = '';
// Handle file upload
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_FILES['file']['name'][0])) {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$allowedExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'zip', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'doc', 'docx', 'xls', 'xlsx'];
$uploaded = 0;
$errors = [];
foreach ($_FILES['file']['name'] as $i => $name) {
if ($_FILES['file']['error'][$i] !== UPLOAD_ERR_OK) continue;
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExt)) {
$errors[] = htmlspecialchars($name) . ' (niet toegestaan type)';
continue;
}
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name);
$dest = rtrim($fullPath, '/') . '/' . $filename;
$n = 1;
while (file_exists($dest)) {
$p = pathinfo($filename);
$dest = rtrim($fullPath, '/') . '/' . $p['filename'] . '_' . $n . '.' . ($p['extension'] ?? $ext);
$n++;
}
if (move_uploaded_file($_FILES['file']['tmp_name'][$i], $dest)) {
$uploaded++;
} else {
$errors[] = htmlspecialchars($name);
}
}
if ($uploaded > 0) {
$message = $uploaded . ' bestand(en) geüpload.';
$messageType = 'success';
}
if (!empty($errors)) {
$message .= ' Fouten: ' . implode(', ', $errors);
$messageType = $messageType ?: 'danger';
}
}
}
$items = scanContentDir($fullPath, $subdir);
$route = 'content';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleContentEdit(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$contentDir = $config['content_dir'];
$file = $_GET['file'] ?? '';
$file = str_replace(['../', '..\\'], '', $file);
$filePath = rtrim($contentDir, '/') . '/' . $file;
$message = '';
$messageType = '';
// Validate path
$realPath = realpath($filePath);
$realContentDir = realpath($contentDir);
if (!$realPath || !$realContentDir || strpos($realPath, $realContentDir) !== 0) {
header('Location: admin.php?route=content');
exit;
}
$fileExt = pathinfo($filePath, PATHINFO_EXTENSION);
$isEditable = in_array($fileExt, ['md', 'php', 'html']);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
// Handle rename
$newFilename = trim($_POST['filename'] ?? '');
if (!empty($newFilename)) {
$newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename);
$newFilename .= '.' . $fileExt;
$parentDir = dirname($filePath);
$newFilePath = $parentDir . '/' . $newFilename;
$realParentDir = realpath($parentDir);
if ($realParentDir && strpos($realParentDir, $realContentDir) === 0) {
if ($newFilePath !== $filePath && !file_exists($newFilePath)) {
rename($filePath, $newFilePath);
$filePath = $newFilePath;
$newFile = dirname($file) . '/' . $newFilename;
$file = ltrim($newFile, './');
}
}
}
// Save content only for editable files
if ($isEditable) {
$content = $_POST['content'] ?? '';
$layout = $_POST['layout'] ?? '';
if ($layout && in_array($layout, ['sidebar-content', 'content', 'sidebar', 'content-sidebar', 'content-sidebar-reverse'])) {
$content = updateContentFrontmatter($content, 'layout', $layout);
}
$plugins = isset($_POST['plugins']) && is_array($_POST['plugins'])
? implode(', ', array_map('trim', $_POST['plugins']))
: '';
$content = updateContentFrontmatter($content, 'plugins', $plugins);
file_put_contents($filePath, $content);
}
$message = 'Bestand opgeslagen.';
$messageType = 'success';
}
}
if ($isEditable) {
$fileContent = file_get_contents($filePath);
$currentLayout = parseFrontmatterField($fileContent, 'layout', 'sidebar-content');
} else {
$fileContent = '';
$currentLayout = 'sidebar-content';
}
$fileName = basename($filePath);
$route = 'content-edit';
// Get available plugins for per-page visibility
$pluginsDir = $config['plugins_dir'];
$availablePlugins = [];
if (is_dir($pluginsDir)) {
$items = scandir($pluginsDir);
sort($items);
foreach ($items as $item) {
if ($item[0] === '.') continue;
$pluginPath = $pluginsDir . '/' . $item;
if (!is_dir($pluginPath) || !file_exists($pluginPath . '/' . $item . '.php')) continue;
$hasConfig = file_exists($pluginPath . '/config.json');
$pluginConfig = $hasConfig ? json_decode(file_get_contents($pluginPath . '/config.json'), true) : [];
if (!($pluginConfig['viewable'] ?? true)) continue;
$availablePlugins[] = $item;
}
}
$currentPlugins = parseFrontmatterField($fileContent ?? '', 'plugins', '');
$selectedPlugins = !empty($currentPlugins)
? array_map('trim', explode(',', $currentPlugins))
: [];
require __DIR__ . '/../admin/templates/layout.php';
}
function handleContentNew(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$contentDir = $config['content_dir'];
$dir = $_GET['dir'] ?? '';
$dir = str_replace(['../', '..\\'], '', $dir);
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$filename = trim($_POST['filename'] ?? '');
$content = $_POST['content'] ?? '';
$type = $_POST['type'] ?? 'md';
if (empty($filename)) {
$message = 'Bestandsnaam is verplicht.';
$messageType = 'danger';
} else {
// Sanitize filename
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $filename);
if (!preg_match('/\.(md|php|html)$/', $filename)) {
$filename .= '.' . $type;
}
$targetDir = rtrim($contentDir, '/') . '/' . $dir;
$filePath = $targetDir . '/' . $filename;
if (file_exists($filePath)) {
$message = 'Bestand bestaat al.';
$messageType = 'danger';
} else {
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
// Build frontmatter
$frontmatterLines = [];
$layout = $_POST['layout'] ?? '';
if ($layout && $layout !== 'sidebar-content' && in_array($layout, ['sidebar-content', 'content', 'sidebar', 'content-sidebar', 'content-sidebar-reverse'])) {
$frontmatterLines[] = 'layout: ' . $layout;
}
$plugins = isset($_POST['plugins']) && is_array($_POST['plugins'])
? implode(', ', array_map('trim', $_POST['plugins']))
: '';
if (!empty($plugins)) {
$frontmatterLines[] = 'plugins: ' . $plugins;
}
if (!empty($frontmatterLines)) {
$content = "---\n" . implode("\n", $frontmatterLines) . "\n---\n" . $content;
}
file_put_contents($filePath, $content);
header('Location: admin.php?route=content&dir=' . urlencode($dir));
exit;
}
}
}
}
// Get available plugins for per-page visibility
$pluginsDir = $config['plugins_dir'];
$availablePlugins = [];
if (is_dir($pluginsDir)) {
$items = scandir($pluginsDir);
sort($items);
foreach ($items as $item) {
if ($item[0] === '.') continue;
$pluginPath = $pluginsDir . '/' . $item;
if (!is_dir($pluginPath) || !file_exists($pluginPath . '/' . $item . '.php')) continue;
$hasConfig = file_exists($pluginPath . '/config.json');
$pluginConfig = $hasConfig ? json_decode(file_get_contents($pluginPath . '/config.json'), true) : [];
if (!($pluginConfig['viewable'] ?? true)) continue;
$availablePlugins[] = $item;
}
}
$route = 'content-new';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleContentDelete(AdminAuth $auth, array $config): void
{
$contentDir = $config['content_dir'];
$file = $_GET['file'] ?? '';
$file = str_replace(['../', '..\\'], '', $file);
$filePath = rtrim($contentDir, '/') . '/' . $file;
$realPath = realpath($filePath);
$realContentDir = realpath($contentDir);
if ($_SERVER['REQUEST_METHOD'] === 'POST'
&& $auth->verifyCsrf($_POST['csrf_token'] ?? '')
&& $realPath && $realContentDir
&& strpos($realPath, $realContentDir) === 0
) {
if (is_file($filePath)) {
unlink($filePath);
}
}
$dir = dirname($file);
header('Location: admin.php?route=content&dir=' . urlencode($dir === '.' ? '' : $dir));
exit;
}
function handleContentDirCreate(AdminAuth $auth, array $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: admin.php?route=content');
exit;
}
$contentDir = $config['content_dir'];
$subdir = $_GET['dir'] ?? '';
$subdir = str_replace(['../', '..\\'], '', $subdir);
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
header('Location: admin.php?route=content&dir=' . urlencode($subdir));
exit;
}
$dirname = trim($_POST['dirname'] ?? '');
if (!empty($dirname)) {
$dirname = preg_replace('/[^a-zA-Z0-9._-]/', '-', $dirname);
$targetDir = rtrim($contentDir, '/') . '/' . ($subdir ? $subdir . '/' . $dirname : $dirname);
if (!file_exists($targetDir)) {
mkdir($targetDir, 0755, true);
}
}
header('Location: admin.php?route=content&dir=' . urlencode($subdir));
exit;
}
function handleContentDirRename(AdminAuth $auth, array $config): void
{
$contentDir = $config['content_dir'];
$dir = $_GET['dir'] ?? '';
$dir = str_replace(['../', '..\\'], '', $dir);
$fullPath = rtrim($contentDir, '/') . '/' . $dir;
$realPath = realpath($fullPath);
$realContentDir = realpath($contentDir);
if (!$realPath || !$realContentDir || strpos($realPath, $realContentDir) !== 0 || !is_dir($fullPath)) {
header('Location: admin.php?route=content');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$parentDirPath = dirname($fullPath);
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$newName = trim($_POST['new_name'] ?? '');
if (!empty($newName)) {
$newName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newName);
$newPath = $parentDirPath . '/' . $newName;
if (!file_exists($newPath)) {
rename($fullPath, $newPath);
}
}
}
$parentRelative = dirname($dir);
header('Location: admin.php?route=content&dir=' . urlencode($parentRelative === '.' ? '' : $parentRelative));
exit;
}
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$route = 'content-dir-rename';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleContentMove(AdminAuth $auth, array $config): void
{
$contentDir = $config['content_dir'];
$item = $_GET['item'] ?? '';
$item = str_replace(['../', '..\\'], '', $item);
$item = ltrim($item, './');
$fullPath = rtrim($contentDir, '/') . '/' . $item;
$realPath = realpath($fullPath);
$realContentDir = realpath($contentDir);
if (!$realPath || !$realContentDir || strpos($realPath, $realContentDir) !== 0) {
header('Location: admin.php?route=content');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$targetDir = trim($_POST['target_dir'] ?? '');
$targetDir = str_replace(['../', '..\\'], '', $targetDir);
$targetFullPath = rtrim($contentDir, '/') . '/' . $targetDir;
$realTarget = realpath($targetFullPath);
if ($realTarget && strpos($realTarget, $realContentDir) === 0 && is_dir($realTarget)) {
$destPath = $realTarget . '/' . basename($fullPath);
if (!file_exists($destPath)) {
rename($fullPath, $destPath);
}
}
}
$parentRelative = is_file($fullPath) ? dirname($item) : dirname($item);
header('Location: admin.php?route=content&dir=' . urlencode($parentRelative === '.' ? '' : $parentRelative));
exit;
}
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
// Get all available content subdirectories for the move dropdown
$dirs = getAllContentDirs($contentDir, $realContentDir);
$route = 'content-move';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleContentDirDelete(AdminAuth $auth, array $config): void
{
$contentDir = $config['content_dir'];
$dir = $_GET['dir'] ?? '';
$dir = str_replace(['../', '..\\'], '', $dir);
$fullPath = rtrim($contentDir, '/') . '/' . $dir;
$realPath = realpath($fullPath);
$realContentDir = realpath($contentDir);
if ($_SERVER['REQUEST_METHOD'] === 'POST'
&& $auth->verifyCsrf($_POST['csrf_token'] ?? '')
&& $realPath && $realContentDir
&& strpos($realPath, $realContentDir) === 0
&& is_dir($fullPath)
) {
// Only delete empty directories
$items = array_diff(scandir($fullPath), ['.', '..']);
if (empty($items)) {
rmdir($fullPath);
}
}
$parentDir = dirname($dir);
header('Location: admin.php?route=content&dir=' . urlencode($parentDir === '.' ? '' : $parentDir));
exit;
}
function handleConfig(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$configJson = $config['config_json'];
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$jsonContent = $_POST['config_content'] ?? '';
$parsed = json_decode($jsonContent, true);
if ($parsed === null && json_last_error() !== JSON_ERROR_NONE) {
$message = 'Ongeldige JSON: ' . json_last_error_msg();
$messageType = 'danger';
} else {
file_put_contents($configJson, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Configuratie opgeslagen.';
$messageType = 'success';
$jsonContent = null; // reload from file
}
}
}
$siteConfig = file_exists($configJson) ? file_get_contents($configJson) : '{}';
if (isset($jsonContent)) {
$siteConfig = $jsonContent;
}
$route = 'config';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleTheme(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$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';
}
}
}
}
// Load active theme name
$configData = json_decode(file_get_contents($configJson), true) ?? [];
$activeTheme = $configData['active_theme'] ?? 'default';
// Load all themes
$themes = [];
if (is_dir($themesDir)) {
foreach (scandir($themesDir) as $item) {
if ($item[0] === '.') continue;
$themeJson = $themesDir . '/' . $item . '/theme.json';
if (is_dir($themesDir . '/' . $item) && file_exists($themeJson)) {
$themeData = json_decode(file_get_contents($themeJson), true) ?? [];
$themes[$item] = $themeData;
}
}
}
// 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 handlePlugins(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$pluginsDir = $config['plugins_dir'];
$plugins = [];
if (is_dir($pluginsDir)) {
foreach (scandir($pluginsDir) as $item) {
if ($item[0] === '.') continue;
$pluginPath = $pluginsDir . '/' . $item;
if (!is_dir($pluginPath)) continue;
$hasConfig = file_exists($pluginPath . '/config.json');
$pluginConfig = $hasConfig ? json_decode(file_get_contents($pluginPath . '/config.json'), true) : [];
$hasMainFile = file_exists($pluginPath . '/' . $item . '.php');
$hasReadme = file_exists($pluginPath . '/README.md');
$plugins[] = [
'name' => $item,
'path' => $pluginPath,
'viewable' => $pluginConfig['viewable'] ?? true,
'config' => $pluginConfig,
'has_config' => $hasConfig,
'has_main' => $hasMainFile,
'has_readme' => $hasReadme,
];
}
}
$route = 'plugins';
require __DIR__ . '/../admin/templates/layout.php';
}
function handlePluginConfig(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$pluginsDir = $config['plugins_dir'];
$pluginName = $_GET['plugin'] ?? '';
$pluginName = preg_replace('/[^a-zA-Z0-9_-]/', '', $pluginName);
$pluginPath = $pluginsDir . '/' . $pluginName;
$configFile = $pluginPath . '/config.json';
$message = '';
$messageType = '';
if (empty($pluginName) || !is_dir($pluginPath)) {
header('Location: admin.php?route=plugins');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$postConfig = $_POST['config'] ?? [];
$currentConfig = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : [];
$newConfig = array_replace_recursive($currentConfig, $postConfig);
// Convert checkbox values: unchecked checkboxes are not sent
foreach (array_keys($currentConfig) as $key) {
if (is_bool($currentConfig[$key])) {
$newConfig[$key] = isset($postConfig[$key]) && $postConfig[$key] === '1';
}
}
file_put_contents($configFile, json_encode($newConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Configuratie opgeslagen voor plugin: ' . htmlspecialchars($pluginName);
$messageType = 'success';
}
}
$pluginConfig = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : [];
$route = 'plugins-config';
require __DIR__ . '/../admin/templates/layout.php';
}
function handlePluginEdit(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$pluginsDir = $config['plugins_dir'];
$pluginName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['plugin'] ?? '');
$pluginPath = $pluginsDir . '/' . $pluginName;
$pluginFile = $pluginPath . '/' . $pluginName . '.php';
$message = '';
$messageType = '';
if (empty($pluginName) || !is_dir($pluginPath) || !file_exists($pluginFile)) {
header('Location: admin.php?route=plugins');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$content = $_POST['content'] ?? '';
file_put_contents($pluginFile, $content);
$message = 'Plugin opgeslagen.';
$messageType = 'success';
// Clear opcache if available
if (function_exists('opcache_invalidate')) {
opcache_invalidate($pluginFile, true);
}
}
}
$fileContent = file_get_contents($pluginFile);
$route = 'plugins-edit';
require __DIR__ . '/../admin/templates/layout.php';
}
function handlePluginNew(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$pluginsDir = $config['plugins_dir'];
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$pluginName = trim($_POST['plugin_name'] ?? '');
$pluginDesc = trim($_POST['plugin_desc'] ?? '');
if (empty($pluginName)) {
$message = 'Plugin naam is verplicht.';
$messageType = 'danger';
} elseif (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $pluginName)) {
$message = 'Ongeldige plugin naam. Gebruik alleen letters, cijfers en underscores. Begin met een letter.';
$messageType = 'danger';
} else {
$pluginPath = $pluginsDir . '/' . $pluginName;
if (is_dir($pluginPath)) {
$message = 'Plugin met deze naam bestaat al.';
$messageType = 'danger';
} else {
mkdir($pluginPath, 0755, true);
$pluginFile = $pluginPath . '/' . $pluginName . '.php';
$boilerplate = "<?php\n\nclass $pluginName\n{\n";
$boilerplate .= " private ?CMSAPI \$api = null;\n private array \$config;\n\n";
$boilerplate .= " public function __construct()\n {\n \$this->config = [\n 'viewable' => true,\n ];\n }\n\n";
$boilerplate .= " public function setAPI(CMSAPI \$api): void\n {\n \$this->api = \$api;\n }\n\n";
$boilerplate .= " public function getSidebarContent(): string\n {\n return '';\n }\n\n";
$boilerplate .= " public function getConfig(): array\n {\n return \$this->config;\n }\n\n";
$boilerplate .= " public function setConfig(array \$config): void\n {\n \$this->config = array_merge(\$this->config, \$config);\n }\n}";
file_put_contents($pluginFile, $boilerplate);
$configFile = $pluginPath . '/config.json';
file_put_contents($configFile, json_encode(['viewable' => true], JSON_PRETTY_PRINT));
if ($pluginDesc) {
$readme = "# $pluginName\n\n$pluginDesc\n";
file_put_contents($pluginPath . '/README.md', $readme);
}
header('Location: admin.php?route=plugins');
exit;
}
}
}
}
$route = 'plugins-new';
require __DIR__ . '/../admin/templates/layout.php';
}
function handlePluginToggle(AdminAuth $auth, array $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: admin.php?route=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.php?route=plugins');
exit;
}
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$configFile = $pluginPath . '/config.json';
if (file_exists($configFile)) {
$pluginConfig = json_decode(file_get_contents($configFile), true);
$pluginConfig['viewable'] = !($pluginConfig['viewable'] ?? true);
file_put_contents($configFile, json_encode($pluginConfig, JSON_PRETTY_PRINT));
} else {
file_put_contents($configFile, json_encode(['viewable' => false], JSON_PRETTY_PRINT));
}
}
header('Location: admin.php?route=plugins');
exit;
}
function handlePluginDelete(AdminAuth $auth, array $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: admin.php?route=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.php?route=plugins');
exit;
}
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$di = new RecursiveDirectoryIterator($pluginPath, RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
$file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath());
}
rmdir($pluginPath);
}
header('Location: admin.php?route=plugins');
exit;
}
function handleMedia(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$assetsDir = $config['assets_dir'];
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
if (!empty($_FILES['file']['name'][0]) && is_array($_FILES['file']['name'])) {
if (!is_dir($assetsDir)) {
mkdir($assetsDir, 0755, true);
}
$allowedExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'zip', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'doc', 'docx', 'xls', 'xlsx'];
$uploaded = 0;
$errors = [];
foreach ($_FILES['file']['name'] as $i => $name) {
if ($_FILES['file']['error'][$i] !== UPLOAD_ERR_OK) continue;
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExt)) {
$errors[] = htmlspecialchars($name) . ' (niet toegestaan type)';
continue;
}
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name);
$dest = $assetsDir . '/' . $filename;
$n = 1;
while (file_exists($dest)) {
$p = pathinfo($filename);
$dest = $assetsDir . '/' . $p['filename'] . '_' . $n . '.' . ($p['extension'] ?? $ext);
$n++;
}
if (move_uploaded_file($_FILES['file']['tmp_name'][$i], $dest)) {
$uploaded++;
} else {
$errors[] = htmlspecialchars($name);
}
}
if ($uploaded > 0) {
$message = $uploaded . ' bestand(en) geüpload.';
$messageType = 'success';
}
if (!empty($errors)) {
$message .= ' Fouten: ' . implode(', ', $errors);
$messageType = $messageType ?: 'danger';
}
}
if (!empty($_POST['delete'])) {
$file = basename($_POST['delete']);
$path = $assetsDir . '/' . $file;
if (file_exists($path)) {
unlink($path);
$message = 'Bestand verwijderd.';
$messageType = 'success';
}
}
}
}
// List files
$files = [];
if (is_dir($assetsDir)) {
foreach (scandir($assetsDir) as $item) {
if ($item[0] === '.' || is_dir($assetsDir . '/' . $item)) continue;
$ext = strtolower(pathinfo($item, PATHINFO_EXTENSION));
$isImage = in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
$files[] = [
'name' => $item,
'url' => '/-assets/' . $item,
'size' => filesize($assetsDir . '/' . $item),
'is_image' => $isImage,
'ext' => $ext,
'modified' => date('Y-m-d H:i', filemtime($assetsDir . '/' . $item)),
];
}
}
// Sort newest first
usort($files, fn($a, $b) => strcmp($b['modified'], $a['modified']));
$route = 'media';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleMediaList(AdminAuth $auth, array $config): void
{
$contentDir = realpath(__DIR__ . '/../content');
$files = [];
if ($contentDir === false || !is_dir($contentDir)) {
header('Content-Type: application/json');
echo json_encode([]);
exit;
}
$mediaExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'mp3', 'wav', 'ogg', 'mp4', 'webm'];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($contentDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $path => $info) {
if ($info->isDir()) continue;
$rel = substr($path, strlen($contentDir) + 1);
if (strpos($rel, '.') === 0) continue;
$ext = strtolower($info->getExtension());
if (!in_array($ext, $mediaExts)) continue;
$isImage = in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
$isAudio = in_array($ext, ['mp3', 'wav', 'ogg']);
$isVideo = in_array($ext, ['mp4', 'webm']);
$files[] = [
'name' => $info->getFilename(),
'url' => '/-media/' . $rel,
'ext' => $ext,
'is_image' => $isImage,
'is_audio' => $isAudio,
'is_video' => $isVideo,
];
}
usort($files, fn($a, $b) => strcasecmp($a['name'], $b['name']));
header('Content-Type: application/json');
echo json_encode($files);
exit;
}
function handleUsers(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$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 === 'add') {
$result = $auth->addUser(
trim($_POST['username'] ?? ''),
$_POST['password'] ?? '',
$_POST['role'] ?? 'admin'
);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'delete') {
$result = $auth->deleteUser($_POST['delete_username'] ?? '');
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'change_password') {
$result = $auth->changePassword(
$_POST['pw_username'] ?? '',
$_POST['new_password'] ?? ''
);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
}
}
}
$users = $auth->getUsers();
$route = 'users';
require __DIR__ . '/../admin/templates/layout.php';
}
// --- Frontmatter helpers ---
function parseFrontmatterField(string $content, string $key, string $default = ''): string
{
if (preg_match('/^---\s*\n(.*?)\n---\s*\n/s', $content, $matches)) {
$metaContent = $matches[1];
foreach (explode("\n", $metaContent) as $line) {
if (strpos($line, $key . ':') === 0) {
[, $value] = explode(':', $line, 2);
return trim($value, " \t'\"");
}
}
}
return $default;
}
function updateContentFrontmatter(string $content, string $key, string $value): string
{
if (preg_match('/^(---\s*\n)(.*?)(\n---\s*\n.*)$/s', $content, $matches)) {
$metaContent = $matches[2];
$body = $matches[3];
$found = false;
$lines = explode("\n", $metaContent);
foreach ($lines as $i => $line) {
if (strpos($line, $key . ':') === 0) {
$lines[$i] = $key . ': ' . $value;
$found = true;
break;
}
}
if (!$found) {
$lines[] = $key . ': ' . $value;
}
return $matches[1] . implode("\n", $lines) . $body;
} else {
return "---\n" . $key . ': ' . $value . "\n---\n" . $content;
}
}
// --- Helper functions ---
function countFiles(string $dir, array $extensions): int
{
$count = 0;
if (!is_dir($dir)) return 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile() && in_array($file->getExtension(), $extensions)) {
$count++;
}
}
return $count;
}
function countDirs(string $dir): int
{
if (!is_dir($dir)) return 0;
$count = 0;
foreach (scandir($dir) as $item) {
if ($item[0] !== '.' && is_dir($dir . '/' . $item)) $count++;
}
return $count;
}
function dirSize(string $dir): int
{
$size = 0;
if (!is_dir($dir)) return 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) $size += $file->getSize();
}
return $size;
}
function formatSize(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 1) . ' ' . $units[$i];
}
function scanContentDir(string $fullPath, string $subdir): array
{
$items = [];
if (!is_dir($fullPath)) return $items;
foreach (scandir($fullPath) as $item) {
if ($item[0] === '.') continue;
// Skip assets directory in content listing
if ($item === 'assets' && is_dir($itemPath)) continue;
$itemPath = $fullPath . '/' . $item;
$relativePath = $subdir ? $subdir . '/' . $item : $item;
$items[] = [
'name' => $item,
'path' => $relativePath,
'is_dir' => is_dir($itemPath),
'size' => is_file($itemPath) ? formatSize(filesize($itemPath)) : '',
'modified' => date('d-m-Y H:i', filemtime($itemPath)),
'extension' => is_file($itemPath) ? pathinfo($item, PATHINFO_EXTENSION) : '',
];
}
// Sort: directories first, then files alphabetically
usort($items, function ($a, $b) {
if ($a['is_dir'] !== $b['is_dir']) return $b['is_dir'] - $a['is_dir'];
return strcasecmp($a['name'], $b['name']);
});
return $items;
}
function getAllContentDirs(string $contentDir, string $realContentDir): array
{
$dirs = [];
if (!is_dir($contentDir)) return $dirs;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($contentDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $path => $fileInfo) {
if (!$fileInfo->isDir()) continue;
$realPath = $fileInfo->getRealPath();
if (strpos($realPath, $realContentDir) !== 0) continue;
$relative = substr($realPath, strlen($realContentDir) + 1);
if ($relative === false || $relative === '') continue;
// Skip hidden directories (starting with -)
if (strpos(basename($relative), '-') === 0) continue;
$dirs[] = $relative;
}
sort($dirs);
return $dirs;
}