__DIR__ . '/../var/cache/twig', 'auto_reload' => true, 'debug' => true, ]); // Add Twig functions $twig->addFunction(new \Twig\TwigFunction('get_country_flag', function($country) { return GeoIP::getCountryFlagEmoji($country); })); $twig->addFunction(new \Twig\TwigFunction('get_country_name', function($country) { return GeoIP::getCountryName($country); })); // Add Twig global for user role and permission checks $twig->addGlobal('user_role', $auth->getCurrentRole()); $twig->addGlobal('user_real_role', $auth->getRealRole()); $twig->addGlobal('has_role_override', $auth->hasRoleOverride()); $twig->addGlobal('roles', AdminAuth::getRoles()); $twig->addFunction(new \Twig\TwigFunction('has_permission', function($route) use ($auth) { return $auth->hasPermission($route); })); $twig->addFunction(new \Twig\TwigFunction('role_label', function($role) { return AdminAuth::getRoleLabel($role); })); // Load admin interface translations // admin_language is resolved from: config admin_language -> language.default -> 'nl' function loadAdminTranslations(?array $siteConfig = null): array { $adminLang = $siteConfig['admin_language'] ?? ($siteConfig['language']['default'] ?? 'nl'); $langDir = __DIR__ . '/../language/'; $file = $langDir . $adminLang . '/admin.php'; if (file_exists($file)) { $t = include $file; if (is_array($t)) { return $t; } } // Fallback to Dutch admin translations $fallback = $langDir . 'nl/admin.php'; if (file_exists($fallback)) { $t = include $fallback; if (is_array($t)) { return $t; } } return []; } $siteConfigForI18n = file_exists($appConfig['config_json']) ? (json_decode(file_get_contents($appConfig['config_json']), true) ?? []) : []; $adminTranslations = loadAdminTranslations($siteConfigForI18n); $adminLangCode = $siteConfigForI18n['admin_language'] ?? ($siteConfigForI18n['language']['default'] ?? 'nl'); $twig->addGlobal('ta', $adminTranslations); $twig->addGlobal('admin_lang', $adminLangCode); $twig->addFunction(new \Twig\TwigFunction('ta', function($key) use ($adminTranslations) { return $adminTranslations[$key] ?? $key; })); // Initialize admin PluginManager for plugin-provided admin pages and menu items $enabledPluginsList = $siteConfigForI18n['enabled_plugins'] ?? []; $siteDefaultLangForPlugins = $siteConfigForI18n['language']['default'] ?? 'nl'; $adminPluginManager = new PluginManager(__DIR__ . '/../plugins', $enabledPluginsList, $siteDefaultLangForPlugins); $adminPluginAPI = new AdminPluginAPI($siteConfigForI18n); $adminPluginAPI->setPluginManager($adminPluginManager); $adminPluginManager->setAPI($adminPluginAPI); $adminPluginMenuItems = $adminPluginManager->getAdminMenuItems(); $twig->addGlobal('plugin_admin_menu', $adminPluginMenuItems); // Plugin translations for the admin context. // System plugins resolve against the admin language; content plugins against // the current content language (handled per-plugin in the API). For the Twig // sidebar we load the admin-language pack of every plugin. $adminPluginTranslations = $adminPluginManager->getAllPluginTranslations($adminLangCode, 'admin'); $twig->addGlobal('ta_plugins', $adminPluginTranslations); $twig->addFunction(new \Twig\TwigFunction('tap', function($plugin, $key) use ($adminPluginTranslations) { return $adminPluginTranslations[$plugin][$key] ?? $key; })); $twig->addFunction(new \Twig\TwigFunction('plugin_menu_label', function($item) use ($adminPluginTranslations) { $plugin = $item['plugin'] ?? ''; $labelKey = $item['label_key'] ?? ''; if ($plugin !== '' && $labelKey !== '' && isset($adminPluginTranslations[$plugin][$labelKey])) { return $adminPluginTranslations[$plugin][$labelKey]; } return $item['label'] ?? ($item['route'] ?? ''); })); // Routing $route = $_GET['route'] ?? ''; // Helper to get sidebar color function getSidebarColor($config) { $siteConfig = file_exists($config['config_json']) ? json_decode(file_get_contents($config['config_json']), true) : []; $activeTheme = $siteConfig['active_theme'] ?? 'default'; $themeFile = __DIR__ . "/../themes/{$activeTheme}/theme.json"; if (file_exists($themeFile)) { $theme = json_decode(file_get_contents($themeFile), true); return $theme['header_color'] ?? '#0a369d'; } return '#0a369d'; } // Essential plugins that cannot be disabled or deleted function getProtectedPlugins(): array { return ['Navigation']; } function isProtectedPlugin(string $pluginName): bool { return in_array($pluginName, getProtectedPlugins(), true); } // Public routes (no auth required) if ($route === 'login') { $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $result = $auth->login($_POST['username'] ?? '', $_POST['password'] ?? '', $_POST['csrf_token'] ?? ''); if ($result['success']) { header('Location: /admin/dashboard'); exit; } $error = $result['message']; } echo $twig->render('login.twig', [ 'error' => $error, 'csrf_token' => $auth->getCsrfToken(), ]); exit; } // All other routes require authentication if (!$auth->isAuthenticated()) { header('Location: /admin/login'); exit; } $user = $auth->getCurrentUser(); $csrf = $auth->getCsrfToken(); $userRole = $auth->getCurrentRole(); $siteConfig = file_exists($appConfig['config_json']) ? json_decode(file_get_contents($appConfig['config_json']), true) : []; // Check route permission (except dashboard and role-switch/reset which real admins always need) if ($route !== '' && $route !== 'dashboard' && $route !== 'role-switch' && $route !== 'role-reset' && !$auth->hasPermission($route)) { http_response_code(403); echo $twig->render('pages/error.twig', [ 'user' => $user, 'route' => '', 'csrf_token' => $csrf, 'error_code' => 403, 'error_title' => 'Geen toegang', 'error_message' => 'Je hebt geen rechten om deze pagina te bekijken.', 'sidebar_color' => getSidebarColor($appConfig), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', ]); exit; } // Authenticated routes // Check if a plugin handles this route (e.g. 'statistics', 'logs') $pluginRouteMatch = $adminPluginManager->resolveAdminRoute($route); if ($pluginRouteMatch !== null) { // Permission check: use the permission declared by the plugin (default 'plugins') $requiredPermission = $pluginRouteMatch['permission'] ?? 'plugins'; // Role check: if the plugin declares required_roles, the current role must be in the list (admin always passes) $requiredRoles = $pluginRouteMatch['required_roles'] ?? null; $currentRole = $auth->getCurrentRole(); $roleOk = $requiredRoles === null || $currentRole === 'admin' || in_array($currentRole, $requiredRoles, true); if (($requiredPermission !== 'dashboard' && !$auth->hasPermission($requiredPermission)) || !$roleOk) { http_response_code(403); echo $twig->render('pages/error.twig', [ 'user' => $user, 'route' => '', 'csrf_token' => $csrf, 'error_code' => 403, 'error_title' => $adminTranslations['no_permission_title'] ?? 'Geen toegang', 'error_message' => $adminTranslations['no_permission'] ?? 'Geen toegang.', 'sidebar_color' => getSidebarColor($appConfig), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', ]); exit; } $pluginHtml = $adminPluginManager->dispatchAdminRoute($pluginRouteMatch['plugin'], $pluginRouteMatch['action']); if ($pluginHtml !== null) { // Wrap plugin output in admin layout echo $twig->render('pages/plugin-page.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'sidebar_color' => getSidebarColor($appConfig), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', 'plugin_content' => $pluginHtml, ]); exit; } // If plugin returned null, fall through to 404 } switch ($route) { case 'logout': $auth->logout(); header('Location: /admin/login'); exit; case 'role-switch': handleRoleSwitch($auth, $appConfig); break; case 'role-reset': handleRoleReset($auth, $appConfig); break; case 'content': handleContent($auth, $appConfig, $twig, $user, $csrf, $siteConfig); break; case 'content-files': handleContentFiles($auth, $appConfig, $twig, $user, $csrf, $siteConfig); break; case 'content-file-upload': handleContentFileUpload($auth, $appConfig, $user); break; case 'content-file-delete': handleContentFileDelete($auth, $appConfig, $user); break; case 'content-file-move': handleContentFileMove($auth, $appConfig, $twig, $user, $csrf); break; case 'content-dir-create-in': handleContentDirCreateIn($auth, $appConfig, $user); break; case 'content-dir-rename-in': handleContentDirRenameIn($auth, $appConfig, $twig, $user, $csrf); break; case 'content-dir-delete-in': handleContentDirDeleteIn($auth, $appConfig, $user); break; case 'content-edit': handleContentEdit($auth, $appConfig, $twig, $user, $csrf, $siteConfig); break; case 'content-new': handleContentNew($auth, $appConfig, $twig, $user, $csrf, $siteConfig); break; case 'content-delete': handleContentDelete($auth, $appConfig); break; case 'content-dir-create': handleContentDirCreate($auth, $appConfig); break; case 'content-dir-rename': handleContentDirRename($auth, $appConfig, $twig, $user, $csrf); break; case 'content-move': handleContentMove($auth, $appConfig, $twig, $user, $csrf); break; case 'content-backup': handleContentBackup($auth, $appConfig, $twig, $user, $csrf); break; case 'content-restore': handleContentRestore($auth, $appConfig); break; case 'content-git-init': handleContentGitInit($auth, $appConfig); break; case 'content-git-commit': handleContentGitCommit($auth, $appConfig); break; case 'content-git-restore': handleContentGitRestore($auth, $appConfig); break; case 'content-dir-delete': handleContentDirDelete($auth, $appConfig); break; case 'config': handleConfig($auth, $appConfig, $twig, $user, $csrf); break; case 'security': handleSecurity($auth, $appConfig, $twig, $user, $csrf); break; case 'theme': handleTheme($auth, $appConfig, $twig, $user, $csrf, $siteConfig); break; case 'theme-new': handleThemeNew($auth, $appConfig, $twig, $user, $csrf); break; case 'theme-edit': handleThemeEdit($auth, $appConfig, $twig, $user, $csrf); break; case 'theme-file-upload': handleThemeFileUpload($auth, $appConfig, $user); break; case 'theme-file-delete': handleThemeFileDelete($auth, $appConfig, $user); break; case 'theme-file-move': handleThemeFileMove($auth, $appConfig, $twig, $user, $csrf); break; case 'theme-activate': handleThemeActivate($auth, $appConfig, $user); break; case 'theme-delete': handleThemeDelete($auth, $appConfig, $user); break; case 'theme-scss': handleThemeScss($auth, $appConfig, $user); break; case 'plugins': handlePlugins($auth, $appConfig, $twig, $user, $csrf); break; case 'plugins-new': handlePluginsNew($auth, $appConfig, $twig, $user, $csrf); break; case 'plugins-edit': handlePluginsEdit($auth, $appConfig, $twig, $user, $csrf); break; case 'plugins-file-upload': handlePluginsFileUpload($auth, $appConfig, $user); break; case 'plugins-file-delete': handlePluginsFileDelete($auth, $appConfig, $user); break; case 'plugins-file-move': handlePluginsFileMove($auth, $appConfig, $twig, $user, $csrf); break; case 'plugins-config': handlePluginsConfig($auth, $appConfig, $twig, $user, $csrf); break; case 'plugins-toggle': handlePluginsToggle($auth, $appConfig); break; case 'plugins-delete': handlePluginsDelete($auth, $appConfig); break; case 'users': handleUsers($auth, $appConfig, $twig, $user, $csrf); break; case 'users-edit': handleUsersEdit($auth, $appConfig, $twig, $user, $csrf); break; case 'users-new': handleUsersNew($auth, $appConfig, $twig, $user, $csrf); break; case 'guide': handleGuide($auth, $appConfig, $twig, $user, $csrf); break; case 'update': handleUpdate($auth, $appConfig, $twig, $user, $csrf); break; case 'media': handleMedia($auth, $appConfig, $twig, $user, $csrf); break; case 'media-list': handleMediaList($auth, $appConfig); break; default: handleDashboard($auth, $appConfig, $twig, $user, $csrf, $siteConfig); } // ============================================================================ // HANDLER FUNCTIONS // ============================================================================ function handleDashboard($auth, $config, $twig, $user, $csrf, $siteConfig): void { $contentDir = $config['content_dir']; $pluginsDir = $config['plugins_dir']; $configJson = $config['config_json']; $versionFile = __DIR__ . '/../version.php'; $versionInfo = []; if (file_exists($versionFile)) { $verData = include $versionFile; $versionInfo = is_array($verData) ? $verData : []; } if (empty($versionInfo['version'])) { $versionInfo['version'] = '0.0.0'; } $stats = [ 'pages' => countFiles($contentDir, ['md', 'php', 'html']), 'directories' => countDirs($contentDir), 'config_exists' => file_exists($configJson), 'content_size' => formatSize(dirSize($contentDir)), 'php_version' => PHP_VERSION, 'cms_version' => $versionInfo['version'], 'os' => PHP_OS_FAMILY . ' (' . php_uname('r') . ')', ]; // Build plugin overview (name => enabled status) $pluginOverview = []; $enabledPlugins = $siteConfig['enabled_plugins'] ?? []; if (is_dir($pluginsDir)) { foreach (glob($pluginsDir . '/*', GLOB_ONLYDIR) as $pluginDir) { $pluginName = basename($pluginDir); $pluginOverview[$pluginName] = [ 'enabled' => in_array($pluginName, $enabledPlugins, true), ]; } } ksort($pluginOverview); echo $twig->render('pages/dashboard.twig', [ 'user' => $user, 'route' => 'dashboard', 'csrf_token' => $csrf, 'stats' => $stats, 'site_config' => $siteConfig, 'plugin_overview' => $pluginOverview, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', ]); } /** * Handle role switch (POST): admin temporarily switches to another role for testing. */ function handleRoleSwitch($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/dashboard'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/dashboard?error=' . urlencode('Ongeldige CSRF token.')); exit; } $newRole = $_POST['new_role'] ?? ''; $result = $auth->switchRole($newRole); adminLog($config, $result['success'] ? 'info' : 'warning', $user['username'] . ' rol-switch: ' . $result['message']); header('Location: /admin/dashboard?msg=' . urlencode($result['message'])); exit; } /** * Handle role reset (POST): admin resets back to their real role. */ function handleRoleReset($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/dashboard'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/dashboard?error=' . urlencode('Ongeldige CSRF token.')); exit; } $result = $auth->resetRole(); adminLog($config, $result['success'] ? 'info' : 'warning', $user['username'] . ' rol-reset: ' . $result['message']); header('Location: /admin/dashboard?msg=' . urlencode($result['message'])); exit; } function handleContent($auth, $config, $twig, $user, $csrf, $siteConfig): void { $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); echo $twig->render('pages/content.twig', [ 'user' => $user, 'route' => 'content', 'csrf_token' => $csrf, 'subdir' => $subdir, 'items' => $items, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Content file-browser editor (Fase 1-3 van content-consistentie TODO). * Spiegel van plugins-edit/theme-edit, maar dan voor de content_dir. * * Toont een geneste bestandsboom zijbalk + CodeMirror editor voor .md/.php/.html. * Ondersteunt: nieuw bestand, opslaan, uploaden, verwijderen, verplaatsen, * map aanmaken/hernoemen/verwijderen, backup/git acties (Fase 5). * * Path-traversal bescherming: realpath() + prefix-check op content_dir. * Taal-prefix (nl./en.) wordt getoond in de boom maar niet gestript uit bestandsnamen * (content gebruikt taal-prefix in bestandsnamen, anders dan plugins/themes). */ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): void { $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content'); exit; } // Editable extensions within content (consistent met bestaande content-edit) $editableExts = ['md', 'php', 'html']; // Resolve the requested file (default: first editable file found at root, else index.md) $relFile = $_GET['file'] ?? ''; $relFile = str_replace(['../', '..\\', './'], '', $relFile); $relFile = ltrim($relFile, '/'); // Carry over flash messages from redirect (upload/delete/move handlers) $message = ''; $messageType = ''; if (isset($_GET['msg']) && is_string($_GET['msg']) && $_GET['msg'] !== '') { $message = $_GET['msg']; $messageType = $_GET['msgtype'] ?? 'info'; } // Resolve real path of the file $realPath = $relFile !== '' ? realpath($realContentDir . '/' . $relFile) : false; if ($relFile !== '' && ($realPath === false || strpos($realPath, $realContentDir) !== 0)) { // Invalid path — reset $relFile = ''; $realPath = false; } // Handle POST actions: new_file, new_dir, save, rename_dir, delete_dir if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } elseif (($_POST['action'] ?? '') === 'new_file') { $inDir = str_replace(['../', '..\\', './'], '', $_POST['in_dir'] ?? ''); $inDir = ltrim($inDir, '/'); foreach (explode('/', $inDir) as $seg) { if ($seg === '..' || $seg === '.') { $inDir = ''; break; } } $newName = trim($_POST['new_filename'] ?? ''); $ext = strtolower(trim($_POST['new_ext'] ?? 'md')); if (!in_array($ext, $editableExts, true)) $ext = 'md'; $newName = preg_replace('/[^a-zA-Z0-9._\-\/]/', '', $newName); $newName = ltrim($newName, '/'); $newName = str_replace(['../', '..\\', './'], '', $newName); $segments = explode('/', $newName); $traversal = false; foreach ($segments as $seg) { if ($seg === '..' || $seg === '.') { $traversal = true; break; } } // Strip an existing content extension from the name only if it matches a known content ext; we append $ext $lastSeg = end($segments); if ($lastSeg !== false) { $lastPathInfo = pathinfo($lastSeg); if (isset($lastPathInfo['extension']) && in_array(strtolower($lastPathInfo['extension']), $editableExts, true)) { $segments[key($segments)] = $lastPathInfo['filename']; } } $newName = implode('/', $segments); if ($newName === '' || $newName === '.' || $traversal) { $message = 'Ongeldige bestandsnaam.'; $messageType = 'danger'; } else { $newRel = ($inDir ? $inDir . '/' : '') . $newName . '.' . $ext; $newFullPath = $realContentDir . '/' . $newRel; $parentPath = dirname($newFullPath); if (!is_dir($parentPath)) @mkdir($parentPath, 0755, true); $parentReal = realpath($parentPath); if ($parentReal === false || strpos($parentReal, $realContentDir) !== 0) { $message = 'Ongeldig pad.'; $messageType = 'danger'; } else { $newReal = $parentReal . '/' . basename($newFullPath); if (file_exists($newReal)) { $message = 'Bestand bestaat al.'; $messageType = 'danger'; } else { // Build frontmatter with author metadata (zoals handleContentNew) $now = date('Y-m-d H:i:s'); $frontmatter = "---\nlayout: full_content\nauthor_name: " . ($user['author_name'] ?? $user['username'] ?? '') . "\nauthor_email: " . ($user['author_email'] ?? '') . "\ncreated: " . $now . "\n---\n\n"; $stub = $ext === 'md' ? $frontmatter . "# Nieuwe pagina\n\n" : ($ext === 'php' ? "Nieuwe pagina\n"); $written = @file_put_contents($newReal, $stub); if ($written !== false) { adminLog($config, 'info', $user['username'] . ' creëerde content bestand ' . $newRel); $relFile = $newRel; $realPath = $newReal; $message = 'Bestand aangemaakt.'; $messageType = 'success'; } else { $err = error_get_last(); $message = 'Bestand aanmaken mislukt: ' . ($err['message'] ?? 'onbekend'); $messageType = 'danger'; } } } } } elseif (($_POST['action'] ?? '') === 'new_dir') { $newDir = trim($_POST['dirname'] ?? ''); $inDir = str_replace(['../', '..\\', './'], '', $_POST['in_dir'] ?? ''); $inDir = ltrim($inDir, '/'); $newDir = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newDir); if ($newDir === '' || $newDir === '.' || $newDir === '..') { $message = 'Ongeldige mapnaam.'; $messageType = 'danger'; } else { $newFullPath = $realContentDir . '/' . ($inDir ? $inDir . '/' : '') . $newDir; $parentReal = realpath($realContentDir . '/' . $inDir); if ($parentReal === false || strpos($parentReal, $realContentDir) !== 0) { $message = 'Ongeldig pad.'; $messageType = 'danger'; } elseif (file_exists($newFullPath)) { $message = 'Map bestaat al.'; $messageType = 'danger'; } else { if (@mkdir($newFullPath, 0755, true)) { adminLog($config, 'info', $user['username'] . ' creëerde content map ' . ($inDir ? $inDir . '/' : '') . $newDir); $message = 'Map aangemaakt.'; $messageType = 'success'; } else { $message = 'Map aanmaken mislukt.'; $messageType = 'danger'; } } } } elseif (($_POST['action'] ?? '') === 'rename_dir') { $dirRel = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); $newName = preg_replace('/[^a-zA-Z0-9._-]/', '-', trim($_POST['newname'] ?? '')); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { $dirRel = ''; break; } } if ($dirRel === '' || $newName === '' || $newName === '.' || $newName === '..') { $message = 'Ongeldige invoer voor hernoemen.'; $messageType = 'danger'; } else { $fullPath = $realContentDir . '/' . $dirRel; $realDir = realpath($fullPath); $parentReal = realpath(dirname($fullPath)); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realContentDir) !== 0 || !$parentReal || strpos($parentReal, $realContentDir) !== 0) { $message = 'Map niet gevonden.'; $messageType = 'danger'; } else { $newPath = $parentReal . '/' . $newName; if (file_exists($newPath)) { $message = 'Naam bestaat al.'; $messageType = 'danger'; } elseif (rename($realDir, $newPath)) { adminLog($config, 'info', $user['username'] . ' hernoemde content map ' . $dirRel . ' naar ' . $newName); $message = 'Map hernoemd.'; $messageType = 'success'; // Update $relFile if it was inside the renamed dir if ($relFile !== '' && strpos($relFile, $dirRel . '/') === 0) { $relFile = dirname($dirRel) . '/' . $newName . '/' . substr($relFile, strlen($dirRel) + 1); $relFile = ltrim($relFile, '/'); $realPath = realpath($realContentDir . '/' . $relFile); } } else { $message = 'Hernoemen mislukt.'; $messageType = 'danger'; } } } } elseif (($_POST['action'] ?? '') === 'delete_dir') { $dirRel = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { $dirRel = ''; break; } } if ($dirRel === '') { $message = 'Ongeldige map.'; $messageType = 'danger'; } else { $fullPath = $realContentDir . '/' . $dirRel; $realDir = realpath($fullPath); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realContentDir) !== 0) { $message = 'Map niet gevonden.'; $messageType = 'danger'; } else { // Only allow deleting empty directories (consistent met bestaande content-dir-delete) $entries = array_diff(scandir($realDir), ['.', '..']); // Allow .bak directory to be deleted with its contents $isEmpty = true; foreach ($entries as $e) { if ($e[0] !== '.') { $isEmpty = false; break; } } if (!$isEmpty) { $message = 'Map moet leeg zijn (verborgen bestanden zoals .bak worden genegeerd).'; $messageType = 'danger'; } elseif (@rmdir($realDir)) { adminLog($config, 'info', $user['username'] . ' verwijderde content map ' . $dirRel); $message = 'Map verwijderd.'; $messageType = 'success'; // Reset relFile if it was inside the deleted dir if ($relFile !== '' && strpos($relFile, $dirRel . '/') === 0) { $relFile = ''; $realPath = false; } } else { $message = 'Map verwijderen mislukt.'; $messageType = 'danger'; } } } } else { // Save the currently selected file $content = $_POST['content'] ?? ''; $layout = $_POST['layout'] ?? ''; if ($realPath && is_file($realPath)) { $ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION)); if (in_array($ext, $editableExts, true)) { if ($layout && $ext === 'md') { $content = updateContentFrontmatter($content, 'layout', $layout); } $plugins = isset($_POST['plugins']) && is_array($_POST['plugins']) ? implode(', ', array_map('trim', $_POST['plugins'])) : ''; if ($ext === 'md') { $content = updateContentFrontmatter($content, 'plugins', $plugins); } backupContentFile($realPath); file_put_contents($realPath, $content); adminLog($config, 'info', $user['username'] . ' bewerkte content ' . $relFile); $message = 'Bestand opgeslagen.'; $messageType = 'success'; } else { $message = 'Dit bestandstype kan niet bewerkt worden.'; $messageType = 'danger'; } } else { $message = 'Bestand niet gevonden.'; $messageType = 'danger'; } } } // Gather the content file tree (geneste boom, skipt .bak/.git) $files = scanContentFiles($contentDir); // If no file selected, pick the first editable file at root if ($relFile === '' && $realPath === false) { foreach ($files as $node) { if (!$node['is_dir'] && in_array($node['extension'], $editableExts, true)) { $relFile = $node['path']; $realPath = realpath($realContentDir . '/' . $relFile); break; } } } // Load content of the selected file (if editable) $isEditable = false; $fileContent = ''; $fileExt = ''; $fileName = ''; if ($realPath && is_file($realPath)) { $fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION)); $fileName = basename($realPath); if (in_array($fileExt, $editableExts, true)) { $isEditable = true; $fileContent = file_get_contents($realPath); } } // Theme layouts for the layout selector (spiegel van handleContentEdit) $themeLayouts = []; $themeDefaultLayout = 'full_content'; $activeThemeName = $siteConfig['active_theme'] ?? 'default'; $themeDir = __DIR__ . "/../themes/{$activeThemeName}"; $themeJsonFile = $themeDir . '/theme.json'; if (file_exists($themeJsonFile)) { $themeJson = json_decode(file_get_contents($themeJsonFile), true); $themeDefaultLayout = $themeJson['config']['default_template'] ?? 'full_content'; if (isset($themeJson['template']) && is_array($themeJson['template'])) { foreach ($themeJson['template'] as $key => $twigFile) { if (in_array($key, ['guide'], true)) continue; $themeLayouts[$key] = $twigFile; } } } // Available plugins for the plugins checkbox group $pluginsDir = $config['plugins_dir']; $availablePlugins = []; if (is_dir($pluginsDir)) { foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) { $pluginName = basename($pluginDir); if ($pluginName !== '.' && $pluginName !== '..' && file_exists($pluginDir . '/plugin.json')) { $availablePlugins[] = $pluginName; } } } // Current frontmatter values for the selected file $currentLayout = $isEditable ? extractFrontmatterValue($fileContent, 'layout') : ''; $currentPlugins = $isEditable ? extractFrontmatterValue($fileContent, 'plugins') : ''; $selectedPlugins = array_map('trim', explode(',', $currentPlugins)); if ($selectedPlugins === ['']) $selectedPlugins = []; $currentAuthorName = $isEditable ? extractFrontmatterValue($fileContent, 'author_name') : ''; $currentAuthorEmail = $isEditable ? extractFrontmatterValue($fileContent, 'author_email') : ''; $currentCreated = $isEditable ? extractFrontmatterValue($fileContent, 'created') : ''; $currentLang = $relFile ? extractLanguagePrefix($relFile) : 'nl'; $fileDir = $relFile ? trim(dirname($relFile), '.\\/') : ''; // Git status for Fase 5 (content git integratie in zijbalk) $gitStatus = ['available' => false, 'initialized' => false, 'dirty' => false, 'branch' => '', 'ahead' => 0, 'behind' => 0, 'lastCommit' => '']; $gitDir = $realContentDir . '/.git'; $gitStatus['available'] = $is_dir = is_dir($gitDir); if ($gitStatus['available']) { $gitStatus['initialized'] = true; $branch = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git rev-parse --abbrev-ref HEAD 2>/dev/null'); $gitStatus['branch'] = trim($branch ?? ''); $dirty = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git status --porcelain 2>/dev/null'); $gitStatus['dirty'] = trim($dirty ?? '') !== ''; $lastCommit = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git log -1 --format="%h %s (%cr)" 2>/dev/null'); $gitStatus['lastCommit'] = trim($lastCommit ?? ''); } $route = 'content-files'; echo $twig->render('pages/content-files.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'relFile' => $relFile, 'fileDir' => $fileDir, 'fileName' => $fileName, 'fileExt' => $fileExt, 'fileContent' => $fileContent, 'isEditable' => $isEditable, 'files' => $files, 'editableExts' => $editableExts, 'currentLayout' => $currentLayout ?: $themeDefaultLayout, 'themeLayouts' => $themeLayouts, 'themeDefaultLayout' => $themeDefaultLayout, 'activeThemeName' => $activeThemeName, 'availablePlugins' => $availablePlugins, 'selectedPlugins' => $selectedPlugins, 'currentLang' => $currentLang, 'currentAuthorName' => $currentAuthorName, 'currentAuthorEmail' => $currentAuthorEmail, 'currentCreated' => $currentCreated, 'gitStatus' => $gitStatus, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => $isEditable, 'message' => $message, 'message_type' => $messageType, ]); } /** * Handle file upload into a content directory (Fase 2). * Mirrors handlePluginsFileUpload/handleThemeFileUpload. * Allowed: images, video, audio, pdf, zip, office docs, css, scss, js, json, html, md. */ function handleContentFileUpload($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-files'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-files'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content-files'); exit; } // Optional subdir within content/ (e.g. "blog" or "-assets") $subdir = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $subdir = ltrim($subdir, '/'); foreach (explode('/', $subdir) as $seg) { if ($seg === '..' || $seg === '.') { $subdir = ''; break; } } $targetDir = $subdir === '' ? $realContentDir : $realContentDir . '/' . $subdir; $realTarget = realpath($targetDir); if ($realTarget === false) { if (!@mkdir($targetDir, 0755, true)) { header('Location: /admin/content-files'); exit; } $realTarget = realpath($targetDir); } if (!$realTarget || strpos($realTarget, $realContentDir) !== 0 || !is_dir($realTarget)) { header('Location: /admin/content-files'); exit; } $allowedExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'zip', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'mov', 'avi', 'doc', 'docx', 'xls', 'xlsx', 'css', 'scss', 'js', 'json', 'html', 'md']; $uploaded = 0; $errors = []; if (!empty($_FILES['file']['name'])) { 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, true)) { $errors[] = htmlspecialchars($name) . ' (niet toegestaan type)'; continue; } $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name); $dest = $realTarget . '/' . $filename; $n = 1; while (file_exists($dest)) { $p = pathinfo($filename); $dest = $realTarget . '/' . $p['filename'] . '_' . $n . '.' . ($p['extension'] ?? $ext); $n++; } if (move_uploaded_file($_FILES['file']['tmp_name'][$i], $dest)) { $uploaded++; } else { $errors[] = htmlspecialchars($name); } } } $msg = []; $type = 'info'; if ($uploaded > 0) { $msg[] = $uploaded . ' bestand(en) geüpload.'; $type = 'success'; adminLog($config, 'info', $user['username'] . ' uploadde naar content/' . ($subdir ? $subdir . '/' : '') . ' (' . $uploaded . ' bestanden)'); } if (!empty($errors)) { $msg[] = 'Fouten: ' . implode(', ', $errors); $type = $type === 'success' ? 'success' : 'danger'; } $redir = '/admin/content-files'; if (!empty($msg)) { $redir .= '?msg=' . urlencode(implode(' ', $msg)) . '&msgtype=' . urlencode($type); } header('Location: ' . $redir); exit; } /** * Handle deletion of a single file inside content (Fase 2). * Refuses to delete directories, dotfiles, and paths that escape content_dir. */ function handleContentFileDelete($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-files'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-files'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content-files'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/content-files'); exit; } } $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/content-files'); exit; } $fullPath = $realContentDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realContentDir) !== 0 || !is_file($realPath)) { header('Location: /admin/content-files'); exit; } $deleted = @unlink($realPath); if ($deleted) { adminLog($config, 'info', $user['username'] . ' verwijderde content/' . $relFile); $msg = 'Bestand verwijderd.'; $type = 'success'; } else { $err = error_get_last(); $msg = 'Verwijderen mislukt: ' . ($err['message'] ?? 'onbekend'); $type = 'danger'; } header('Location: /admin/content-files?msg=' . urlencode($msg) . '&msgtype=' . urlencode($type)); exit; } /** * Handle moving a single file within content to another folder within content (Fase 2/3). * Mirrors handlePluginsFileMove/handleThemeFileMove. */ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content-files'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_GET['file'] ?? $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/content-files'); exit; } } $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/content-files'); exit; } $fullPath = $realContentDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realContentDir) !== 0 || !is_file($realPath)) { header('Location: /admin/content-files'); exit; } $message = ''; $messageType = ''; // Collect all directories within content (as relative paths), excluding the file's own dir $directories = ['']; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($realContentDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); $currentDir = trim(dirname($relFile), '.\\/'); foreach ($iterator as $file) { if (!$file->isDir()) continue; $realDir = $file->getRealPath(); if ($realDir === false || strpos($realDir, $realContentDir) !== 0) continue; $relDir = ltrim(substr($realDir, strlen($realContentDir) + 1), '/\\'); $relDir = str_replace('\\', '/', $relDir); if ($relDir === $currentDir) continue; if ($relDir !== '' && $relDir[0] === '.') continue; $directories[] = $relDir; } $directories = array_unique($directories); sort($directories); if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $dest = str_replace(['../', '..\\', './'], '', $_POST['destination'] ?? ''); $dest = ltrim($dest, '/'); foreach (explode('/', $dest) as $seg) { if ($seg === '..' || $seg === '.') { $message = 'Ongeldige doelmap.'; $messageType = 'danger'; break; } } if ($message === '') { $destDir = $dest === '' ? $realContentDir : $realContentDir . '/' . $dest; $realDestDir = realpath($destDir); if (!$realDestDir || !is_dir($realDestDir) || strpos($realDestDir, $realContentDir) !== 0) { $message = 'Doelmap bestaat niet.'; $messageType = 'danger'; } else { $newPath = $realDestDir . '/' . basename($realPath); if (file_exists($newPath)) { $message = 'Bestand bestaat al op de bestemming.'; $messageType = 'danger'; } elseif (rename($realPath, $newPath)) { adminLog($config, 'info', $user['username'] . ' verplaatste content/' . $relFile . ' naar content/' . ($dest ? $dest . '/' : '') . basename($relFile)); $newRel = $dest === '' ? basename($relFile) : $dest . '/' . basename($relFile); header('Location: /admin/content-files?file=' . urlencode($newRel) . '&msg=' . urlencode('Bestand verplaatst.') . '&msgtype=success'); exit; } else { $err = error_get_last(); $message = 'Verplaatsen mislukt: ' . ($err['message'] ?? 'onbekend'); $messageType = 'danger'; } } } } } echo $twig->render('pages/content-move-form.twig', [ 'user' => $user, 'route' => 'content-file-move', 'csrf_token' => $csrf, 'relFile' => $relFile, 'itemName' => basename($relFile), 'itemDir' => trim(dirname($relFile), '.\\/'), 'directories' => $directories, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Handle creating a directory within content from the editor sidebar (Fase 3). * Variant of handleContentDirCreate that redirects back to content-files. */ function handleContentDirCreateIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-files'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-files'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content-files'); exit; } $inDir = str_replace(['../', '..\\', './'], '', $_POST['in_dir'] ?? ''); $inDir = ltrim($inDir, '/'); foreach (explode('/', $inDir) as $seg) { if ($seg === '..' || $seg === '.') { $inDir = ''; break; } } $dirname = trim($_POST['dirname'] ?? ''); $dirname = preg_replace('/[^a-zA-Z0-9._-]/', '-', $dirname); if ($dirname === '' || $dirname === '.' || $dirname === '..') { header('Location: /admin/content-files?msg=' . urlencode('Ongeldige mapnaam.') . '&msgtype=danger'); exit; } $newPath = $realContentDir . '/' . ($inDir ? $inDir . '/' : '') . $dirname; if (file_exists($newPath)) { header('Location: /admin/content-files?msg=' . urlencode('Map bestaat al.') . '&msgtype=danger'); exit; } if (@mkdir($newPath, 0755, true)) { adminLog($config, 'info', $user['username'] . ' creëerde content map ' . ($inDir ? $inDir . '/' : '') . $dirname); header('Location: /admin/content-files?msg=' . urlencode('Map aangemaakt.') . '&msgtype=success'); } else { header('Location: /admin/content-files?msg=' . urlencode('Map aanmaken mislukt.') . '&msgtype=danger'); } exit; } /** * Handle renaming a directory within content from the editor sidebar (Fase 3). * Variant of handleContentDirRename that redirects back to content-files. */ function handleContentDirRenameIn($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content-files'); exit; } $dirRel = str_replace(['../', '..\\', './'], '', $_GET['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { $dirRel = ''; break; } } $message = ''; $messageType = ''; if ($dirRel !== '') { $fullPath = $realContentDir . '/' . $dirRel; $realDir = realpath($fullPath); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realContentDir) !== 0) { $dirRel = ''; } } if ($_SERVER['REQUEST_METHOD'] === 'POST' && $dirRel !== '') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $newName = preg_replace('/[^a-zA-Z0-9._-]/', '-', trim($_POST['newname'] ?? '')); if ($newName === '' || $newName === '.' || $newName === '..') { $message = 'Ongeldige naam.'; $messageType = 'danger'; } else { $parentReal = realpath(dirname($realContentDir . '/' . $dirRel)); if (!$parentReal || strpos($parentReal, $realContentDir) !== 0) { $message = 'Ongeldig pad.'; $messageType = 'danger'; } else { $newPath = $parentReal . '/' . $newName; if (file_exists($newPath)) { $message = 'Naam bestaat al.'; $messageType = 'danger'; } elseif (rename($realDir, $newPath)) { adminLog($config, 'info', $user['username'] . ' hernoemde content map ' . $dirRel . ' naar ' . $newName); header('Location: /admin/content-files?msg=' . urlencode('Map hernoemd.') . '&msgtype=success'); exit; } else { $message = 'Hernoemen mislukt.'; $messageType = 'danger'; } } } } } if ($dirRel === '') { header('Location: /admin/content-files?msg=' . urlencode('Map niet gevonden.') . '&msgtype=danger'); exit; } echo $twig->render('pages/content-dir-rename-form.twig', [ 'user' => $user, 'route' => 'content-dir-rename-in', 'csrf_token' => $csrf, 'dir' => $dirRel, 'currentName' => basename($dirRel), 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Handle deleting a directory within content from the editor sidebar (Fase 3). * Variant of handleContentDirDelete that redirects back to content-files. * Only allows deleting empty directories (verborgen .bak bestanden genegeerd). */ function handleContentDirDeleteIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-files'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-files'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content-files'); exit; } $dirRel = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/content-files?msg=' . urlencode('Ongeldige map.') . '&msgtype=danger'); exit; } } if ($dirRel === '') { header('Location: /admin/content-files?msg=' . urlencode('Ongeldige map.') . '&msgtype=danger'); exit; } $fullPath = $realContentDir . '/' . $dirRel; $realDir = realpath($fullPath); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realContentDir) !== 0) { header('Location: /admin/content-files?msg=' . urlencode('Map niet gevonden.') . '&msgtype=danger'); exit; } // Only allow empty directories (verborgen .bak/.git genegeerd) $entries = array_diff(scandir($realDir), ['.', '..']); $isEmpty = true; foreach ($entries as $e) { if ($e[0] !== '.') { $isEmpty = false; break; } } if (!$isEmpty) { header('Location: /admin/content-files?msg=' . urlencode('Map moet leeg zijn.') . '&msgtype=danger'); exit; } if (@rmdir($realDir)) { adminLog($config, 'info', $user['username'] . ' verwijderde content map ' . $dirRel); header('Location: /admin/content-files?msg=' . urlencode('Map verwijderd.') . '&msgtype=success'); } else { header('Location: /admin/content-files?msg=' . urlencode('Map verwijderen mislukt.') . '&msgtype=danger'); } exit; } function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): void { $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/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'] ?? ''); $wasRenamed = false; 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); $wasRenamed = true; adminLog($config, 'info', $user['username'] . ' hernoemde ' . basename($filePath) . ' naar ' . $newFilename); $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) { $content = updateContentFrontmatter($content, 'layout', $layout); } $plugins = isset($_POST['plugins']) && is_array($_POST['plugins']) ? implode(', ', array_map('trim', $_POST['plugins'])) : ''; $content = updateContentFrontmatter($content, 'plugins', $plugins); backupContentFile($filePath); file_put_contents($filePath, $content); if (!$wasRenamed) { adminLog($config, 'info', $user['username'] . ' bewerkte ' . basename($filePath)); } $message = 'Bestand opgeslagen.'; $messageType = 'success'; } } } $fileName = basename($filePath); $fileContent = $isEditable ? file_get_contents($filePath) : ''; $currentLayout = extractFrontmatterValue($fileContent, 'layout'); $currentPlugins = extractFrontmatterValue($fileContent, 'plugins'); $currentAuthorName = extractFrontmatterValue($fileContent, 'author_name'); $currentAuthorEmail = extractFrontmatterValue($fileContent, 'author_email'); $currentCreated = extractFrontmatterValue($fileContent, 'created'); $selectedPlugins = array_map('trim', explode(',', $currentPlugins)); if ($selectedPlugins === ['']) $selectedPlugins = []; // Get available layouts from active theme's theme.json $themeLayouts = []; $themeDefaultLayout = 'full_content'; $activeThemeName = $siteConfig['active_theme'] ?? 'default'; $themeDir = __DIR__ . "/../themes/{$activeThemeName}"; $themeJsonFile = $themeDir . '/theme.json'; if (file_exists($themeJsonFile)) { $themeJson = json_decode(file_get_contents($themeJsonFile), true); $themeDefaultLayout = $themeJson['config']['default_template'] ?? 'full_content'; if (isset($themeJson['template']) && is_array($themeJson['template'])) { foreach ($themeJson['template'] as $key => $twigFile) { // Skip internal layouts that users shouldn't select if (in_array($key, ['guide'], true)) continue; $themeLayouts[$key] = $twigFile; } } } else { // Fallback: scan theme directory for .twig files if (is_dir($themeDir)) { foreach (glob($themeDir . '/*.twig') as $layoutFile) { $layoutName = basename($layoutFile, '.twig'); if (!in_array($layoutName, ['base', 'guide'], true)) { $themeLayouts[$layoutName] = $layoutName . '.twig'; } } } } // Get available plugins $pluginsDir = $config['plugins_dir']; $availablePlugins = []; if (is_dir($pluginsDir)) { foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) { $pluginName = basename($pluginDir); if ($pluginName !== '.' && $pluginName !== '..' && file_exists($pluginDir . '/plugin.json')) { $availablePlugins[] = $pluginName; } } } $currentLang = extractLanguagePrefix($file); $route = 'content-edit'; $fileDir = trim(dirname($file), '.\\/'); echo $twig->render('pages/content-edit.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'file' => $file, 'fileDir' => $fileDir, 'fileName' => $fileName, 'fileExt' => $fileExt, 'fileContent' => $fileContent, 'isEditable' => $isEditable, 'currentLayout' => $currentLayout ?: $themeDefaultLayout, 'themeLayouts' => $themeLayouts, 'themeDefaultLayout' => $themeDefaultLayout, 'activeThemeName' => $activeThemeName, 'availablePlugins' => $availablePlugins, 'selectedPlugins' => $selectedPlugins, 'currentLang' => $currentLang, 'currentAuthorName' => $currentAuthorName, 'currentAuthorEmail' => $currentAuthorEmail, 'currentCreated' => $currentCreated, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => $isEditable, 'message' => $message, 'message_type' => $messageType, ]); } function handleContentNew($auth, $config, $twig, $user, $csrf, $siteConfig): void { $contentDir = $config['content_dir']; $dir = $_GET['dir'] ?? ''; $dir = str_replace(['../', '..\\'], '', $dir); $fullPath = rtrim($contentDir, '/') . '/' . $dir; $message = ''; $messageType = ''; // Get available layouts from active theme's theme.json $themeLayouts = []; $themeDefaultLayout = 'full_content'; $activeThemeName = $siteConfig['active_theme'] ?? 'default'; $themeDir = __DIR__ . "/../themes/{$activeThemeName}"; $themeJsonFile = $themeDir . '/theme.json'; if (file_exists($themeJsonFile)) { $themeJson = json_decode(file_get_contents($themeJsonFile), true); $themeDefaultLayout = $themeJson['config']['default_template'] ?? 'full_content'; if (isset($themeJson['template']) && is_array($themeJson['template'])) { foreach ($themeJson['template'] as $key => $twigFile) { if (in_array($key, ['guide'], true)) continue; $themeLayouts[$key] = $twigFile; } } } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $filename = trim($_POST['filename'] ?? ''); $ext = $_POST['extension'] ?? 'md'; $layout = $_POST['layout'] ?? $themeDefaultLayout; if (!empty($filename)) { $filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $filename); $filename .= '.' . $ext; $dest = rtrim($fullPath, '/') . '/' . $filename; if (!file_exists($dest)) { // Build frontmatter with layout and author metadata $now = date('Y-m-d H:i:s'); $frontmatter = "---\n"; $frontmatter .= "layout: " . $layout . "\n"; $frontmatter .= "author_name: " . ($user['author_name'] ?? $user['username'] ?? '') . "\n"; $frontmatter .= "author_email: " . ($user['author_email'] ?? '') . "\n"; $frontmatter .= "created: " . $now . "\n"; $frontmatter .= "---\n\n"; $content = $frontmatter . "# Nieuwe pagina\n\n"; file_put_contents($dest, $content); adminLog($config, 'info', $user['username'] . ' creëerde ' . $filename); header('Location: /admin/content-edit?file=' . urlencode($dir . '/' . $filename)); exit; } else { $message = 'Bestand bestaat al.'; $messageType = 'danger'; } } } } $route = 'content-new'; $availableExtensions = ['md' => 'Markdown', 'php' => 'PHP', 'html' => 'HTML']; echo $twig->render('pages/content-new.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'dir' => $dir, 'availableExtensions' => $availableExtensions, 'themeLayouts' => $themeLayouts, 'themeDefaultLayout' => $themeDefaultLayout, 'activeThemeName' => $activeThemeName, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } function handleContentDelete($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content'); exit; } $contentDir = $config['content_dir']; $file = $_POST['file'] ?? $_GET['file'] ?? ''; $file = str_replace(['../', '..\\'], '', $file); $filePath = rtrim($contentDir, '/') . '/' . $file; if (file_exists($filePath) && is_file($filePath)) { unlink($filePath); adminLog($config, 'info', $user['username'] . ' verwijderde ' . $file); } header('Location: /admin/content?dir=' . urlencode(dirname($file))); exit; } function handleContentDirCreate($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content'); exit; } $contentDir = $config['content_dir']; $subdir = $_GET['dir'] ?? ''; $subdir = str_replace(['../', '..\\'], '', $subdir); $dirname = trim($_POST['dirname'] ?? ''); if (!empty($dirname)) { $dirname = preg_replace('/[^a-zA-Z0-9._-]/', '-', $dirname); $newPath = rtrim($contentDir, '/') . '/' . ($subdir ? $subdir . '/' : '') . $dirname; if (!file_exists($newPath)) { @mkdir($newPath, 0755, true); adminLog($config, 'info', $user['username'] . ' creëerde map ' . $dirname); } } header('Location: /admin/content?dir=' . urlencode($subdir)); exit; } function handleContentDirRename($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $dir = $_GET['dir'] ?? ''; $dir = str_replace(['../', '..\\'], '', $dir); $fullPath = rtrim($contentDir, '/') . '/' . $dir; $message = ''; $messageType = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $newName = trim($_POST['newname'] ?? ''); if (!empty($newName)) { $newName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newName); $parentDir = dirname($fullPath); $newPath = $parentDir . '/' . $newName; if (!file_exists($newPath) && $newPath !== $fullPath) { rename($fullPath, $newPath); adminLog($config, 'info', $user['username'] . ' hernoemde map ' . basename($dir) . ' naar ' . $newName); header('Location: /admin/content?dir=' . urlencode(dirname($dir) . '/' . $newName)); exit; } else { $message = 'Map bestaat al of ongeldige naam.'; $messageType = 'danger'; } } } } $route = 'content-dir-rename'; echo $twig->render('pages/content-dir-form.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'dir' => $dir, 'fullPath' => $fullPath, 'currentName' => basename($dir), 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } function handleContentDirDelete($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content'); exit; } $contentDir = $config['content_dir']; $dir = $_GET['dir'] ?? $_POST['dir'] ?? ''; $dir = str_replace(['../', '..\\'], '', $dir); $fullPath = rtrim($contentDir, '/') . '/' . $dir; if (is_dir($fullPath)) { $files = scandir($fullPath); $files = array_diff($files, ['.', '..']); if (empty($files)) { rmdir($fullPath); adminLog($config, 'info', $user['username'] . ' verwijderde map ' . $dir); } } header('Location: /admin/content?dir=' . urlencode(dirname($dir))); exit; } function handleContentMove($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $item = $_GET['item'] ?? ''; $item = str_replace(['../', '..\\'], '', $item); $fullPath = rtrim($contentDir, '/') . '/' . $item; $message = ''; $messageType = ''; // Get all directories for destination selection $directories = []; $realContentDir = realpath($contentDir); $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($contentDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if ($file->isDir()) { $path = str_replace($realContentDir, '', realpath($file->getPathname())); $path = trim($path, '/\\'); if ($path && $path !== $item && !str_starts_with($path, '-') && !str_starts_with($path, '.')) { $directories[] = $path; } } } sort($directories); if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $dest = trim($_POST['destination'] ?? ''); if (!empty($dest)) { $dest = str_replace(['../', '..\\'], '', $dest); $destPath = rtrim($contentDir, '/') . '/' . $dest; if (is_dir($destPath)) { $newPath = $destPath . '/' . basename($item); if (!file_exists($newPath)) { rename($fullPath, $newPath); adminLog($config, 'info', $user['username'] . ' verplaatste ' . $item . ' naar ' . $dest); header('Location: /admin/content?dir=' . urlencode($dest)); exit; } else { $message = 'Bestand of map bestaat al op de bestemming.'; $messageType = 'danger'; } } } } } $route = 'content-move'; $isDir = is_dir($fullPath); $itemDir = trim(dirname($item), '.\\/'); echo $twig->render('pages/content-move-form.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'item' => $item, 'itemDir' => $itemDir, 'fullPath' => $fullPath, 'itemName' => basename($item), 'isDir' => $isDir, 'directories' => $directories, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } function handleContentBackup($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $projectRoot = $config['codepress_root']; $backup = new ContentBackup($contentDir, $projectRoot); $message = ''; $messageType = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } elseif (isset($_POST['action'])) { $action = $_POST['action']; if ($action === 'download_zip') { $backupDir = $projectRoot . '/var/tmp'; if (!is_dir($backupDir)) { @mkdir($backupDir, 0755, true); } $backupFile = $backupDir . '/content-backup-' . date('YmdHis') . '.zip'; if ($backup->createZipBackup($backupFile)) { adminLog($config, 'info', $user['username'] . ' maakte een content ZIP backup aan'); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . basename($backupFile) . '"'); header('Content-Length: ' . filesize($backupFile)); readfile($backupFile); unlink($backupFile); exit; } else { $message = 'Kon geen ZIP backup maken.'; $messageType = 'danger'; } } } } // Get git info $gitAvailable = $backup->isGitAvailable(); $hasGitRepo = $backup->hasGitRepo(); $gitCommits = []; if ($hasGitRepo) { $logResult = $backup->gitLog(20); $gitCommits = $logResult['commits'] ?? []; } $route = 'content-backup'; echo $twig->render('pages/content-backup.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'git_available' => $gitAvailable, 'has_git_repo' => $hasGitRepo, 'git_commits' => $gitCommits, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } function handleContentRestore($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-backup'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-backup?error=csrf'); exit; } if (empty($_FILES['zipfile']['tmp_name'])) { header('Location: /admin/content-backup?error=nofile'); exit; } $contentDir = $config['content_dir']; $projectRoot = $config['codepress_root']; $backup = new ContentBackup($contentDir, $projectRoot); $result = $backup->restoreFromZip($_FILES['zipfile']['tmp_name']); if ($result['success']) { adminLog($config, 'info', $user['username'] . ' herstelde content uit ZIP backup'); header('Location: /admin/content-backup?restored=1'); } else { adminLog($config, 'warning', $user['username'] . ' - content restore mislukt: ' . $result['message']); header('Location: /admin/content-backup?error=' . urlencode($result['message'])); } exit; } function handleContentGitInit($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-backup'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-backup?error=csrf'); exit; } $backup = new ContentBackup($config['content_dir'], $config['codepress_root']); $result = $backup->gitInit(); if ($result['success']) { adminLog($config, 'info', $user['username'] . ' initialiseerde git in content/'); } header('Location: /admin/content-backup?git=' . urlencode($result['message'])); exit; } function handleContentGitCommit($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-backup'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-backup?error=csrf'); exit; } $message = trim($_POST['commit_message'] ?? ''); $backup = new ContentBackup($config['content_dir'], $config['codepress_root']); $result = $backup->gitCommit($message); if ($result['success']) { adminLog($config, 'info', $user['username'] . ' committe content: ' . $message); } header('Location: /admin/content-backup?git=' . urlencode($result['message'])); exit; } function handleContentGitRestore($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content-backup'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content-backup?error=csrf'); exit; } $commitHash = $_POST['commit'] ?? ''; $backup = new ContentBackup($config['content_dir'], $config['codepress_root']); $result = $backup->gitRestore($commitHash); if ($result['success']) { adminLog($config, 'info', $user['username'] . ' herstelde content naar git commit ' . $commitHash); } header('Location: /admin/content-backup?git=' . urlencode($result['message'])); exit; } function handleConfig($auth, $config, $twig, $user, $csrf): void { $configFile = $config['config_json']; $message = ''; $messageType = ''; $ta = loadAdminTranslations(file_exists($configFile) ? (json_decode(file_get_contents($configFile), true) ?? []) : []); if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = $ta['invalid_csrf'] ?? 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $newConfig = json_decode(file_get_contents($configFile), true) ?? []; $newConfig['site_title'] = $_POST['site_title'] ?? ''; $defaultPage = $_POST['default_page'] ?? 'auto'; if ($defaultPage === 'specific') { $defaultPage = $_POST['default_page_specific'] ?? 'auto'; } $newConfig['default_page'] = $defaultPage; $newConfig['language']['default'] = $_POST['content_language'] ?? 'nl'; $newConfig['admin_language'] = $_POST['admin_language'] ?? 'nl'; backupContentFile($configFile); file_put_contents($configFile, json_encode($newConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); adminLog($config, 'info', $user['username'] . ' bewerkte configuratie'); // Redirect after successful save (PRG pattern) so the new admin // language is applied immediately without a manual reload. header('Location: /admin/config?saved=1'); exit; } } // Show success message after redirect if (isset($_GET['saved'])) { $message = $ta['saved'] ?? 'Configuratie opgeslagen.'; $messageType = 'success'; } $currentConfig = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : []; $route = 'config'; $contentPages = collectContentPages($config['content_dir']); $currentDefaultPage = $currentConfig['default_page'] ?? 'auto'; echo $twig->render('pages/config.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'config' => $currentConfig, 'content_pages' => $contentPages, 'current_default_page' => $currentDefaultPage, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } function handleSecurity($auth, $config, $twig, $user, $csrf): void { $adminConfigFile = $config['admin_config']; $message = ''; $messageType = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $adminConfig = json_decode(file_get_contents($adminConfigFile), true) ?? []; $adminConfig['security']['force_ssl'] = isset($_POST['force_ssl']); $adminConfig['security']['session_timeout'] = (int)($_POST['session_timeout'] ?? 3600); $adminConfig['security']['max_login_attempts'] = (int)($_POST['max_login_attempts'] ?? 5); file_put_contents($adminConfigFile, json_encode($adminConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); adminLog($config, 'info', $user['username'] . ' bewerkte beveiligingsinstellingen'); $message = 'Beveiligingsinstellingen opgeslagen.'; $messageType = 'success'; } } $adminConfig = file_exists($adminConfigFile) ? json_decode(file_get_contents($adminConfigFile), true) : []; $route = 'security'; echo $twig->render('pages/security.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'config' => $adminConfig, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } function handleTheme($auth, $config, $twig, $user, $csrf, $siteConfig): void { $themesDir = __DIR__ . '/../themes'; $themes = []; $activeTheme = $siteConfig['active_theme'] ?? 'default'; foreach (glob($themesDir . '/*', GLOB_ONLYDIR) as $themeDir) { $themeName = basename($themeDir); $themeJson = $themeDir . '/theme.json'; $themeData = [ 'name' => $themeName, 'title' => $themeName, 'active' => $activeTheme === $themeName, 'protected' => $themeName === 'default', 'has_scss' => is_file($themeDir . '/assets/scss/theme.scss'), 'scss_compiled' => false, 'scss_mtime' => null, 'css_mtime' => null, ]; if (file_exists($themeJson)) { $data = json_decode(file_get_contents($themeJson), true); $themeData = array_merge($themeData, $data); } // SCSS compile status: true when compiled CSS exists and is newer than SCSS source $scssFile = $themeDir . '/assets/scss/theme.scss'; $cssFile = $themeDir . '/assets/css_compiled/theme.css'; if (is_file($scssFile)) { $themeData['scss_mtime'] = date('Y-m-d H:i', filemtime($scssFile)); if (is_file($cssFile)) { $themeData['css_mtime'] = date('Y-m-d H:i', filemtime($cssFile)); $themeData['scss_compiled'] = filemtime($cssFile) >= filemtime($scssFile); } } $themes[] = $themeData; } // Sort: active theme first, then alphabetical usort($themes, function ($a, $b) { if ($a['active'] && !$b['active']) return -1; if (!$a['active'] && $b['active']) return 1; return strcasecmp($a['name'], $b['name']); }); $route = 'theme'; echo $twig->render('pages/theme.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'themes' => $themes, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $_GET['msg'] ?? '', 'message_type' => $_GET['msgtype'] ?? 'info', ]); } function handleThemeNew($auth, $config, $twig, $user, $csrf): void { $message = ''; $messageType = ''; // Available base themes (existing themes to copy from) $themesDir = __DIR__ . '/../themes'; $baseThemes = []; foreach (glob($themesDir . '/*', GLOB_ONLYDIR) as $themeDir) { $name = basename($themeDir); $baseThemes[$name] = $name; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $themeName = trim($_POST['name'] ?? ''); $baseTheme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['base_theme'] ?? ''); if (!empty($themeName)) { $themeName = preg_replace('/[^a-zA-Z0-9_-]/', '-', $themeName); $newThemeDir = $themesDir . '/' . $themeName; if (file_exists($newThemeDir)) { $message = 'Thema bestaat al.'; $messageType = 'danger'; } else { if (!@mkdir($newThemeDir, 0755, true)) { $message = 'Kon thema map niet aanmaken.'; $messageType = 'danger'; } else { // Copy from base theme if requested and it exists, otherwise create uniform structure if ($baseTheme !== '' && $baseTheme !== $themeName && is_dir($themesDir . '/' . $baseTheme)) { copyDirRecursive($themesDir . '/' . $baseTheme, $newThemeDir); // Overwrite the copied theme.json title with the new theme name $copiedJsonFile = $newThemeDir . '/theme.json'; if (is_file($copiedJsonFile)) { $copiedJson = json_decode(file_get_contents($copiedJsonFile), true); if (is_array($copiedJson)) { $copiedJson['title'] = ucfirst($themeName); file_put_contents($copiedJsonFile, json_encode($copiedJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } } // Overwrite the copied README.md with a fresh one for the new theme @file_put_contents($newThemeDir . '/README.md', "# " . ucfirst($themeName) . " thema\n\nGekopieerd van `" . $baseTheme . "`. Zie `theme.json` voor layout mapping en `assets/scss/theme.scss` voor styling.\n"); } else { createUniformThemeStructure($newThemeDir, $themeName); } adminLog($config, 'info', $user['username'] . ' creëerde thema ' . $themeName . ($baseTheme ? ' (basis: ' . $baseTheme . ')' : '')); header('Location: /admin/theme-edit?theme=' . urlencode($themeName)); exit; } } } else { $message = 'Thema naam mag niet leeg zijn.'; $messageType = 'danger'; } } } $route = 'theme-new'; echo $twig->render('pages/theme-new.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'base_themes' => $baseThemes, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Create a uniform theme structure (theme.json, layouts, partials, assets/scss, etc.) * @param string $themeDir Absolute path to the new theme directory (already created) * @param string $themeName Sanitized theme name */ function createUniformThemeStructure(string $themeDir, string $themeName): void { // Uniform directories per AGENTS.md / guide @mkdir($themeDir . '/partials', 0755, true); @mkdir($themeDir . '/assets/scss', 0755, true); @mkdir($themeDir . '/assets/css', 0755, true); @mkdir($themeDir . '/assets/js', 0755, true); @mkdir($themeDir . '/assets/img', 0755, true); @mkdir($themeDir . '/assets/fonts', 0755, true); // theme.json with default full_content layout $themeJson = [ 'title' => ucfirst($themeName), 'config' => ['default_template' => 'full_content'], 'template' => ['full_content' => 'full_content.twig'], ]; file_put_contents($themeDir . '/theme.json', json_encode($themeJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); // Minimal base.twig + full_content.twig + partials file_put_contents($themeDir . '/base.twig', "\n\n
\n" . " \n \n" . "Handleiding niet gevonden.
'; } else { $content = file_get_contents($guideFile); // Parse Markdown with Table support if (class_exists('League\CommonMark\Environment\Environment')) { $environment = new \League\CommonMark\Environment\Environment([ 'html_input' => 'strip', 'heading_permalink' => [ 'symbol' => '', 'aria_hidden' => true, 'html_class' => 'heading-permalink', 'id_prefix' => '', 'fragment_prefix' => '', 'apply_id_to_heading' => true, 'insert' => 'after', 'min_heading_level' => 1, 'max_heading_level' => 6, 'title' => 'Permalink', ], ]); $environment->addExtension(new \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension()); $environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension()); $environment->addExtension(new \League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension()); $converter = new \League\CommonMark\MarkdownConverter($environment); $content = $converter->convert($content)->getContent(); } else { $content = nl2br(htmlspecialchars($content)); } } // Build guide navigation sidebar using Navigation plugin $guideNav = ''; $navPluginFile = $rootDir . '/plugins/Navigation/Navigation.php'; if (file_exists($navPluginFile)) { require_once $navPluginFile; if (class_exists('Navigation')) { $navPlugin = new Navigation(); $guideNav = $navPlugin->getSidebarContent(); } } // Build breadcrumbs $breadcrumbs = []; if ($page) { $parts = explode('/', $page); $path = ''; foreach ($parts as $part) { $path .= ($path ? '/' : '') . $part; $breadcrumbs[] = [ 'title' => str_replace('-', ' ', ucfirst($part)), 'url' => $path, ]; } } echo $twig->render('pages/guide.twig', [ 'user' => $user, 'route' => 'guide', 'csrf_token' => $csrf, 'lang' => $lang, 'page' => $page, 'guide_breadcrumbs' => $breadcrumbs, 'guide_lang' => $lang, 'guide_page' => $page, 'content' => $content, 'guide_nav' => $guideNav, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', ]); } function handleUpdate($auth, $config, $twig, $user, $csrf): void { $versionFile = __DIR__ . '/../version.php'; $versionInfo = file_exists($versionFile) ? include $versionFile : []; $versionInfo['version'] = $versionInfo['version'] ?? '0.0.0'; $isGitWritable = is_writable(__DIR__ . '/../.git'); $route = 'update'; echo $twig->render('pages/update.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'version' => $versionInfo['version'], 'isGitWritable' => $isGitWritable, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', ]); } function handleMedia($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $subdir = $_GET['dir'] ?? ''; $subdir = str_replace(['../', '..\\'], '', $subdir); $fullPath = rtrim($contentDir, '/') . '/' . $subdir; $items = scanContentDir($fullPath, $subdir); $route = 'media'; echo $twig->render('pages/media.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'subdir' => $subdir, 'items' => $items, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', ]); } /** * JSON endpoint that returns the list of media files (images, video, audio, * documents), recursively. Used by the in-editor media modal so the user can * pick a file and insert it into the content without leaving the editor. * * Scope: * - Default (no ?plugin=): scans the content directory, URLs are /content/... * - With ?plugin=