__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); })); // admin_language is resolved from: config admin_language -> language.default -> 'nl' /** * Laadt de admin-interface vertalingen voor de opgegeven site-configuratie. * * De admin-taal wordt bepaald uit (in volgorde): `admin_language` in de * site-config, dan `language.default`, dan standaard 'nl'. Wanneer het * taalbestand niet bestaat of geen array teruggeeft, valt de functie terug * op de Nederlandse vertalingen. * * @since 2.6.5 * * @param array|null $siteConfig Site-configuratie array met admin_language en language.default. * @return array Vertaal-string map (key => vertaling). Leeg indien geen bestand gevonden. */ 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'] ?? ''; /** * Bepaalt de sidebar-kleur van het actieve thema. * * Leest `active_theme` uit de site-config en zoekt in het `theme.json` * van dat thema naar een `header_color`. Wanneer het thema of de kleur * ontbreekt, wordt de standaardkleur `#0a369d` teruggegeven. * * @since 2.6.5 * * @param array $config App-configuratie array met de sleutel `config_json`. * @return string CSS-kleurwaarde (bijv. '#0a369d'). */ // 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'; } /** * Geeft de lijst van beschermde plugins die niet uitgeschakeld of verwijderd mogen worden. * * @since 2.6.5 * * @return array Namen van beschermd geworden plugins (bijv. ['Navigation']). */ // Essential plugins that cannot be disabled or deleted function getProtectedPlugins(): array { return ['Navigation']; } /** * Controleert of een plugin in zijn plugin.json `essential: true` heeft. * * Essential plugins worden altijd geladen en kunnen niet worden uitgeschakeld, * bewerkt of verwijderd via de admin. De check is pad-onafhankelijk en leest * de plugin.json uit de standaard plugins-directory. * * @since 2.6.6 * * @param string $pluginName Naam van de plugin om te controleren. * @return bool True wanneer de plugin essential is, anders false. */ function isEssentialPluginByName(string $pluginName): bool { if ($pluginName === '') { return false; } $pluginJsonFile = __DIR__ . '/../plugins/' . $pluginName . '/plugin.json'; if (!file_exists($pluginJsonFile)) { return false; } $data = json_decode(file_get_contents($pluginJsonFile), true); return is_array($data) && ($data['essential'] ?? false) === true; } /** * Controleert of een plugin beschermd is (niet uit te schakelen/verwijderen). * * Een plugin is beschermd wanneer deze op de hardcoded lijst staat * (getProtectedPlugins()) OF in zijn plugin.json `essential: true` heeft. * * @since 2.6.5 * * @param string $pluginName Naam van de plugin om te controleren. * @return bool True wanneer de plugin beschermd is, anders false. */ function isProtectedPlugin(string $pluginName): bool { return in_array($pluginName, getProtectedPlugins(), true) || isEssentialPluginByName($pluginName); } /** * Geeft de lijst van content-type plugins (name => [name, title, type]). * * Wordt gebruikt door de content-editor "zichtbare plugins" selector zodat * alleen content-plugins (geen system-plugins zoals Statistics/Logs/Dashboard) * worden aangeboden. * * Type-resolutie spiegelt handlePlugins: * 1. standaard 'content' * 2. plugin.json 'type' override * 3. regex uit .php 'type' => '...' (fallback als plugin.json geen type bevat) * * @since 2.6.5 * * @param string $pluginsDir Absoluut pad naar de plugins-directory. * @return array Uitsluitend content-type plugins. */ function getContentPlugins(string $pluginsDir): array { $plugins = []; if (!is_dir($pluginsDir)) { return $plugins; } foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) { $pluginName = basename($pluginDir); if ($pluginName === '.' || $pluginName === '..') continue; $pluginJson = $pluginDir . '/plugin.json'; $type = 'content'; $title = ucfirst($pluginName); if (file_exists($pluginJson)) { $data = json_decode(file_get_contents($pluginJson), true); if (is_array($data)) { if (isset($data['type'])) $type = $data['type']; if (isset($data['name'])) $title = $data['name']; elseif (isset($data['title'])) $title = $data['title']; } } if ($type === 'content') { // Fallback: detect type from plugin PHP when plugin.json has no explicit type $pluginFile = $pluginDir . '/' . $pluginName . '.php'; if (file_exists($pluginJson) && !isset($data['type']) && file_exists($pluginFile)) { $source = file_get_contents($pluginFile); if (preg_match("/'type'\s*=>\s*'([^']+)'/", $source, $m)) { $type = $m[1]; } } } if ($type === 'content') { $plugins[] = ['name' => $pluginName, 'title' => $title, 'type' => $type]; } } usort($plugins, function ($a, $b) { return strcasecmp($a['name'], $b['name']); }); return $plugins; } // 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(), 'generated_password' => $auth->getGeneratedPassword(), ]); 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': handleContentFiles($auth, $appConfig, $twig, $user, $csrf, $siteConfig); break; case 'content-list': // Lijst weergave is verwijderd; redirect naar de boom-editor header('Location: /admin/content'); exit; case 'content-files': // Backward compat: redirect to the unified content editor header('Location: /admin/content'); exit; 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-dir-create-in': handleThemeDirCreateIn($auth, $appConfig, $user); break; case 'theme-dir-rename-in': handleThemeDirRenameIn($auth, $appConfig, $twig, $user, $csrf); break; case 'theme-dir-delete-in': handleThemeDirDeleteIn($auth, $appConfig, $user); 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-dir-create-in': handlePluginsDirCreateIn($auth, $appConfig, $user); break; case 'plugins-dir-rename-in': handlePluginsDirRenameIn($auth, $appConfig, $twig, $user, $csrf); break; case 'plugins-dir-delete-in': handlePluginsDirDeleteIn($auth, $appConfig, $user); break; case 'tree-move': handleTreeMove($auth, $appConfig, $user); 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 // ============================================================================ /** * Rendert het admin-dashboard met CMS- en plugin-statistieken. * * Toont o.a. het aantal pagina's, mappen, content-grootte, PHP/CMS-versie * en een overzicht van alle plugins met hun ingeschakelde status. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, plugins_dir, config_json). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @param array $siteConfig Site-configuratie array (uit config.json). * @return void */ 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) // Essential/protected plugins worden altijd als actief getoond, // ongeacht enabled_plugins (consistent met PluginManager::loadPlugins()). $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) || isProtectedPlugin($pluginName), ]; } } 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', ]); } /** * Verwerkt het tijdelijk wisselen van rol (POST). * * Een admin kan via deze actie naar een andere rol schakelen om te testen * hoe de interface eruitziet met die rol. Verwacht een geldige CSRF-token * en een `new_role` POST-veld; stuur daarna door naar het dashboard. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (voor logging). * @return void */ 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; } /** * Verwerkt het terugzetten naar de eigen rol (POST). * * Een admin die via role-switch in een andere rol zat, kan hiermee de * override ongedaan maken en terugkeren naar de echte rol. Verwacht een * geldige CSRF-token en stuurt daarna door naar het dashboard. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (voor logging). * @return void */ 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; } /** * Toont de content-directory met upload- en navigatie-mogelijkheden. * * Toont een lijst van bestanden en mappen in de opgegeven subdir, met * ondersteuning voor uploaden van bestanden (POST). Bevat path-traversal * bescherming via str_replace en trim. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @param array $siteConfig Site-configuratie array (uit config.json). * @return void */ 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). * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, plugins_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @param array $siteConfig Site-configuratie array (uit config.json). * @return void */ 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, '/'); // Optional selected directory (?dir=). A directory is selected when the user // clicks a folder in the tree; it drives the map-detail pane and the default // target for the top "new file / new folder / upload" actions. $selectedDir = $_GET['dir'] ?? ''; $selectedDir = str_replace(['../', '..\\', './'], '', $selectedDir); $selectedDir = ltrim($selectedDir, '/'); foreach (explode('/', $selectedDir) as $seg) { if ($seg === '..' || $seg === '.') { $selectedDir = ''; break; } } // 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; } // Validate the selected directory (?dir=). This is an explicit folder // selection (clicking a folder in the tree). A selected file does NOT set // $selectedDir — the file's parent is tracked separately as $fileDir so // the tree auto-expands the right branch without opening the map pane. if ($selectedDir !== '') { $realSelectedDir = realpath($realContentDir . '/' . $selectedDir); if ($realSelectedDir === false || !is_dir($realSelectedDir) || strpos($realSelectedDir, $realContentDir) !== 0) { $selectedDir = ''; } } // Map-detail info for the right-hand pane (only when a directory is selected) $selectedDirName = $selectedDir !== '' ? basename($selectedDir) : ''; $selectedDirIsRoot = $selectedDir === ''; $selectedDirRealPath = $selectedDir !== '' ? realpath($realContentDir . '/' . $selectedDir) : $realContentDir; $selectedDirParent = $selectedDir !== '' ? trim(dirname($selectedDir), ".\\/") : ''; if ($selectedDirParent === '.') $selectedDirParent = ''; // Count direct children of the selected dir for a quick summary $selectedDirCounts = ['files' => 0, 'dirs' => 0]; if ($selectedDirRealPath && is_dir($selectedDirRealPath)) { foreach (scandir($selectedDirRealPath) as $e) { if ($e === '.' || $e === '..') continue; // Skip dotfiles/dash entries in the summary (consistent with frontend) if ($e[0] === '.' || $e[0] === '-') continue; if (is_dir($selectedDirRealPath . '/' . $e)) $selectedDirCounts['dirs']++; else $selectedDirCounts['files']++; } } // 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 met dit type bestaat al.'; $messageType = 'danger'; } else { // Build frontmatter with layout/created/edited timestamps. // All editable types (md/php/html) support --- frontmatter; the CMS // parseMetadata() extracts it and parsePHP() strips it from output. $now = date('Y-m-d H:i:s'); $frontmatter = "---\nlayout: full_content\ncreated: " . $now . "\nedited: " . $now . "\n---\n\n"; if ($ext === 'md') { $stub = $frontmatter . "# Nieuwe pagina\n\n"; } elseif ($ext === 'php') { $stub = $frontmatter . "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); } // Keep the selected dir in sync with the rename so the // map-detail pane stays on the (renamed) current folder. if ($selectedDir === $dirRel) { $selectedDir = ($selectedDirParent !== '' ? $selectedDirParent . '/' : '') . $newName; } elseif ($selectedDir !== '' && strpos($selectedDir, $dirRel . '/') === 0) { $selectedDir = dirname($dirRel) . '/' . $newName . '/' . substr($selectedDir, strlen($dirRel) + 1); $selectedDir = ltrim($selectedDir, '/'); } // Redirect to the renamed folder so the map-detail pane // re-renders with the new name and tree state. $newDir = $selectedDir !== '' ? $selectedDir : $newName; $redir = '/admin/content?dir=' . urlencode($newDir) . '&msg=' . urlencode('Map hernoemd.') . '&msgtype=success'; header('Location: ' . $redir); exit; } 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'] ?? ''; $newFilename = trim($_POST['new_filename'] ?? ''); if ($realPath && is_file($realPath)) { $ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION)); if (in_array($ext, $editableExts, true)) { 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); // Stamp the last edit time so the editor can show created/edited $content = updateContentFrontmatter($content, 'edited', date('Y-m-d H:i:s')); // Rename if the filename changed if ($newFilename !== '') { $cleanName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename); $cleanName = preg_replace('/\.(md|php|html)$/i', '', $cleanName); $newFull = dirname($realPath) . '/' . $cleanName . '.' . $ext; if ($newFull !== $realPath) { if (file_exists($newFull)) { $message = 'Bestandsnaam bestaat al.'; $messageType = 'danger'; } else { backupContentFile($realPath); file_put_contents($realPath, $content); rename($realPath, $newFull); $realPath = $newFull; $relFile = trim(dirname($relFile), '.\\/') ? trim(dirname($relFile), '.\\/') . '/' . $cleanName . '.' . $ext : $cleanName . '.' . $ext; $renamedTo = $relFile; adminLog($config, 'info', $user['username'] . ' hernoemde content naar ' . $relFile); $message = 'Bestand opgeslagen en hernoemd.'; $messageType = 'success'; } } } if ($message !== 'Bestandsnaam bestaat al.') { backupContentFile($realPath); file_put_contents($realPath, $content); if ($message === '') { adminLog($config, 'info', $user['username'] . ' bewerkte content ' . $relFile); $message = 'Bestand opgeslagen.'; $messageType = 'success'; } // If renamed, redirect to the new URL so the browser URL stays in sync if (isset($renamedTo)) { header('Location: /admin/content?file=' . urlencode($renamedTo) . '&msg=' . urlencode($message) . '&msgtype=success'); exit; } } } 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 AND no directory selected, pick the first editable // file at root as a convenience default. When a directory is selected // (?dir=...) we leave relFile empty so no file is marked active in the tree. if ($relFile === '' && $realPath === false && $selectedDir === '') { 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 = ''; $fileBaseName = ''; if ($realPath && is_file($realPath)) { $fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION)); $fileName = basename($realPath); $fileBaseName = basename($realPath, '.' . $fileExt); 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 content plugins for the plugins multiselect (only active // content-type plugins, never system plugins like Statistics/Logs). $pluginsDir = $config['plugins_dir']; $contentPlugins = getContentPlugins($pluginsDir); // Keep only enabled plugins so the selector reflects what actually runs. // enabled_plugins lives in config.json ($siteConfig), not in app.php. // Essential/protected plugins worden altijd getoond (consistent met // PluginManager::loadPlugins() en handlePlugins()). $enabledPlugins = $siteConfig['enabled_plugins'] ?? []; $availablePlugins = []; foreach ($contentPlugins as $p) { if (in_array($p['name'], $enabledPlugins, true) || isProtectedPlugin($p['name'])) { $availablePlugins[] = $p; } } // 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 = []; $currentCreated = $isEditable ? extractFrontmatterValue($fileContent, 'created') : ''; $currentEdited = $isEditable ? extractFrontmatterValue($fileContent, 'edited') : ''; $currentLang = $relFile ? extractLanguagePrefix($relFile) : 'nl'; $fileDir = $relFile ? trim(dirname($relFile), '.\\/') : ''; // The map-detail pane shows only when a folder is explicitly selected via // ?dir=. Selecting/opening a file keeps the editor pane (right-hand side). $showMapPaneel = $selectedDir !== ''; $route = 'content-files'; echo $twig->render('pages/content-files.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'relFile' => $relFile, 'fileDir' => $fileDir, 'selectedDir' => $selectedDir, 'selectedDirName' => $selectedDirName, 'selectedDirIsRoot' => $selectedDirIsRoot, 'selectedDirParent' => $selectedDirParent, 'selectedDirCounts' => $selectedDirCounts, 'showMapPaneel' => $showMapPaneel, 'fileName' => $fileName, 'fileBaseName' => $fileBaseName, '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, 'currentCreated' => $currentCreated, 'currentEdited' => $currentEdited, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => $isEditable, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het uploaden van bestanden in een content-directory (Fase 2). * * Spiegelt handlePluginsFileUpload/handleThemeFileUpload. Toegestane types: * afbeeldingen, video, audio, pdf, zip, office-docs, css, scss, js, json, html, md. * Bevat path-traversal bescherming via realpath + prefix-check op content_dir. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleContentFileUpload($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content'); 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'); exit; } $realTarget = realpath($targetDir); } if (!$realTarget || strpos($realTarget, $realContentDir) !== 0 || !is_dir($realTarget)) { header('Location: /admin/content'); 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'; $query = []; if ($subdir !== '') $query['dir'] = $subdir; if (!empty($msg)) { $query['msg'] = implode(' ', $msg); $query['msgtype'] = $type; } if (!empty($query)) { $redir .= '?' . http_build_query($query); } header('Location: ' . $redir); exit; } /** * Verwerkt het verwijderen van een enkel bestand binnen content (Fase 2). * * Weigert mappen, dotfiles en paden die buiten content_dir vallen. Na * verwijdering wordt terugverwezen naar de map waar het bestand in stond. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleContentFileDelete($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/content'); exit; } } $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/content'); exit; } $fullPath = $realContentDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realContentDir) !== 0 || !is_file($realPath)) { header('Location: /admin/content'); 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'; } // Redirect back to the folder the file lived in so the map-detail pane stays. $parentDir = trim(dirname($relFile), ".\\/"); $query = ['msg' => $msg, 'msgtype' => $type]; if ($parentDir !== '' && $parentDir !== '.') $query['dir'] = $parentDir; header('Location: /admin/content?' . http_build_query($query)); exit; } /** * Verwerkt het verplaatsen van een bestand binnen content naar een andere map (Fase 2/3). * * Spiegelt handlePluginsFileMove/handleThemeFileMove. Toont een verplaats-formulier * (GET) met een dropdown van beschikbare mappen en voert de hernoemactie (move) * uit op POST. Optioneel kan de bestandsnaam worden gewijzigd (extensie behouden). * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_GET['file'] ?? $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/content'); exit; } } $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/content'); exit; } $fullPath = $realContentDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realContentDir) !== 0 || !is_file($realPath)) { header('Location: /admin/content'); 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; } } // Optional rename: new filename (without extension, extension is preserved) $newName = trim($_POST['new_name'] ?? ''); $ext = strtolower(pathinfo($relFile, PATHINFO_EXTENSION)); if ($newName !== '') { $newName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newName); // Strip any extension the user may have typed (we preserve the original) $newName = preg_replace('/\.(md|php|html)$/i', '', $newName); } else { $newName = basename($relFile, '.' . $ext); } 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 { $newFileName = $newName . '.' . $ext; $newPath = $realDestDir . '/' . $newFileName; if (strtolower($newPath) === strtolower($realPath)) { // No change header('Location: /admin/content?file=' . urlencode($relFile) . '&msg=' . urlencode('Geen wijziging.') . '&msgtype=info'); exit; } if (file_exists($newPath)) { $message = 'Bestand bestaat al op de bestemming.'; $messageType = 'danger'; } elseif (rename($realPath, $newPath)) { $newRel = $dest === '' ? $newFileName : $dest . '/' . $newFileName; adminLog($config, 'info', $user['username'] . ' hernoemde/verplaatste content/' . $relFile . ' naar content/' . $newRel); header('Location: /admin/content?file=' . urlencode($newRel) . '&msg=' . urlencode('Bestand hernoemd/verplaatst.') . '&msgtype=success'); exit; } else { $err = error_get_last(); $message = 'Hernoemen/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, '.' . pathinfo($relFile, PATHINFO_EXTENSION)), 'itemExt' => '.' . pathinfo($relFile, PATHINFO_EXTENSION), 'itemDir' => trim(dirname($relFile), '.\\/'), 'directories' => $directories, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het aanmaken van een map binnen content vanuit de editor-zijbalk (Fase 3). * * Variant van handleContentDirCreate die terugverwijst naar content-files * (de verenigde content-editor). Alleen POST met een geldige CSRF-token. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleContentDirCreateIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content'); 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?msg=' . urlencode('Ongeldige mapnaam.') . '&msgtype=danger'); exit; } $newPath = $realContentDir . '/' . ($inDir ? $inDir . '/' : '') . $dirname; if (file_exists($newPath)) { header('Location: /admin/content?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?msg=' . urlencode('Map aangemaakt.') . '&msgtype=success'); } else { header('Location: /admin/content?msg=' . urlencode('Map aanmaken mislukt.') . '&msgtype=danger'); } exit; } /** * Verwerkt het hernoemen van een map binnen content vanuit de editor-zijbalk (Fase 3). * * Variant van handleContentDirRename die terugverwijst naar content-files. * Toont een hernoem-formulier (GET) en voert de hernoemactie uit op POST. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleContentDirRenameIn($auth, $config, $twig, $user, $csrf): void { $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content'); 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?msg=' . urlencode('Map hernoemd.') . '&msgtype=success'); exit; } else { $message = 'Hernoemen mislukt.'; $messageType = 'danger'; } } } } } if ($dirRel === '') { header('Location: /admin/content?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, ]); } /** * Verwerkt het verwijderen van een map binnen content vanuit de editor-zijbalk (Fase 3). * * Variant van handleContentDirDelete die terugverwijst naar content-files. * Alleen lege mappen mogen verwijderd worden (verborgen .bak bestanden worden * genegeerd bij de leegte-check). Alleen POST met een geldige CSRF-token. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleContentDirDeleteIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/content'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/content'); exit; } $contentDir = $config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) { header('Location: /admin/content'); exit; } $dirRel = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/content?msg=' . urlencode('Ongeldige map.') . '&msgtype=danger'); exit; } } if ($dirRel === '') { header('Location: /admin/content?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?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?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?msg=' . urlencode('Map verwijderd.') . '&msgtype=success'); } else { header('Location: /admin/content?msg=' . urlencode('Map verwijderen mislukt.') . '&msgtype=danger'); } exit; } /** * Toont en verwerkt de content-bestand editor (losse bewerkpagina). * * Laadt een .md/.php/.html bestand uit content_dir, toont de inhoud in een * CodeMirror editor met layout- en plugin-selectoren, en slaat wijzigingen * op (POST). Frontmatter (layout/plugins/edited) wordt bijgewerkt bij opslaan. * Bevat path-traversal bescherming via realpath + prefix-check. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, plugins_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @param array $siteConfig Site-configuratie array (uit config.json). * @return void */ 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 { // 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); $content = updateContentFrontmatter($content, 'edited', date('Y-m-d H:i:s')); backupContentFile($filePath); file_put_contents($filePath, $content); adminLog($config, 'info', $user['username'] . ' bewerkte ' . basename($filePath)); $message = 'Bestand opgeslagen.'; $messageType = 'success'; } } } $fileName = basename($filePath); $fileBaseName = basename($filePath, '.' . $fileExt); $fileContent = $isEditable ? file_get_contents($filePath) : ''; $currentLayout = extractFrontmatterValue($fileContent, 'layout'); $currentPlugins = extractFrontmatterValue($fileContent, 'plugins'); $currentCreated = extractFrontmatterValue($fileContent, 'created'); $currentEdited = extractFrontmatterValue($fileContent, 'edited'); $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 content plugins (only enabled content-type plugins). // enabled_plugins lives in config.json ($siteConfig), not in app.php. // Essential/protected plugins worden altijd getoond (consistent met // PluginManager::loadPlugins() en handlePlugins()). $pluginsDir = $config['plugins_dir']; $contentPlugins = getContentPlugins($pluginsDir); $enabledPlugins = $siteConfig['enabled_plugins'] ?? []; $availablePlugins = []; foreach ($contentPlugins as $p) { if (in_array($p['name'], $enabledPlugins, true) || isProtectedPlugin($p['name'])) { $availablePlugins[] = $p; } } $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, 'fileBaseName' => $fileBaseName, 'fileExt' => $fileExt, 'fileContent' => $fileContent, 'isEditable' => $isEditable, 'currentLayout' => $currentLayout ?: $themeDefaultLayout, 'themeLayouts' => $themeLayouts, 'themeDefaultLayout' => $themeDefaultLayout, 'activeThemeName' => $activeThemeName, 'availablePlugins' => $availablePlugins, 'selectedPlugins' => $selectedPlugins, 'currentLang' => $currentLang, 'currentCreated' => $currentCreated, 'currentEdited' => $currentEdited, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => $isEditable, 'message' => $message, 'message_type' => $messageType, ]); } /** * Toont en verwerkt het aanmaken van een nieuw content-bestand. * * Biedt een formulier om een nieuw .md/.php/.html bestand aan te maken in de * opgegeven map. Op POST wordt het bestand aangemaakt met frontmatter * (layout/created/edited) en een stub, waarna wordt doorverwezen naar de * content-editor. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @param array $siteConfig Site-configuratie array (uit config.json). * @return void */ 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 = strtolower(trim($_POST['extension'] ?? 'md')); if (!in_array($ext, ['md', 'php', 'html'], true)) $ext = '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 created/edited timestamps. // All editable types (md/php/html) support --- frontmatter. $now = date('Y-m-d H:i:s'); $frontmatter = "---\n"; $frontmatter .= "layout: " . $layout . "\n"; $frontmatter .= "created: " . $now . "\n"; $frontmatter .= "edited: " . $now . "\n"; $frontmatter .= "---\n\n"; if ($ext === 'php') { $content = $frontmatter . "Nieuwe pagina\n"; } else { $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 met dit type 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, ]); } /** * Verwerkt het verwijderen van een content-bestand (POST). * * Verwacht een geldige CSRF-token en een `file` POST/GET-veld. Na verwijderen * wordt doorverwezen naar de map waar het bestand in stond. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @return void */ 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; } /** * Verwerkt het aanmaken van een content-map (POST). * * Maakt een nieuwe map aan in de opgegeven subdir. Verwacht een geldige * CSRF-token en een `dirname` POST-veld. Na aanmaken wordt terugverwezen * naar de content-lijst. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @return void */ 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; } /** * Toont en verwerkt het hernoemen van een content-map. * * Toont een hernoem-formulier (GET) en voert de hernoemactie uit op POST. * Na succesvolle hernoeming wordt doorverwezen naar de hernoemde map. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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, ]); } /** * Verwerkt het verwijderen van een lege content-map (POST). * * Verwacht een geldige CSRF-token en verwijdert de map alleen als deze * leeg is. Na verwijderen wordt terugverwezen naar de bovenliggende map. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @return void */ 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; } /** * Toont en verwerkt het verplaatsen van een content-bestand of map. * * Toont een verplaats-formulier (GET) met een dropdown van beschikbare * doelmappen en voert de verplaatsactie uit op POST. Na succes wordt * doorverwezen naar de doelmap. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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, ]); } /** * Toont de content-backup pagina en verwerkt ZIP-backup aanmaken (POST). * * Toont of git beschikbaar is, of er een git-repo is en de git-geschiedenis. * Op POST met actie `download_zip` wordt een ZIP-backup van content gemaakt * en direct als download aangeboden. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, codepress_root). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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, ]); } /** * Verwerkt het herstellen van content uit een geüploade ZIP-backup (POST). * * Verwacht een geldige CSRF-token en een `zipfile` upload. Roept * ContentBackup::restoreFromZip aan en stuurt daarna door naar de * backup-pagina met een statusmelding. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, codepress_root). * @return void */ 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; } /** * Initialiseert een git-repo in de content-directory (POST). * * Verwacht een geldige CSRF-token en roept ContentBackup::gitInit aan. * Na afloop wordt doorverwezen naar de backup-pagina met een statusmelding. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, codepress_root). * @return void */ 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; } /** * Maakt een git-commit in de content-directory (POST). * * Verwacht een geldige CSRF-token en een `commit_message` POST-veld. Roept * ContentBackup::gitCommit aan en stuurt daarna door naar de backup-pagina. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, codepress_root). * @return void */ 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; } /** * Herstelt content naar een specifieke git-commit (POST). * * Verwacht een geldige CSRF-token en een `commit` POST-veld met de commit-hash. * Roept ContentBackup::gitRestore aan en stuurt daarna door naar de backup-pagina. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, codepress_root). * @return void */ 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; } /** * Toont en verwerkt de site-configuratie pagina. * * Bewerkt o.a. site_title, default_page, content_language en admin_language * in config.json. Op succes wordt via PRG doorgestuurd zodat de nieuwe * admin-taal direct wordt toegepast. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (config_json, content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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, ]); } /** * Toont en verwerkt de beveiligingsinstellingen pagina. * * Bewerkt o.a. force_ssl, session_timeout en max_login_attempts in de * admin-config JSON. Op POST (met geldige CSRF-token) worden de waarden * opgeslagen en bevestigd. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (admin_config). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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, ]); } /** * Toont het thema-overzicht met alle geïnstalleerde thema's. * * Toont per thema de naam, of deze actief/beschermd is, SCSS-compile status * en -mtimes. Actieve thema's worden eerst getoond, daarna alfabetisch. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @param array $siteConfig Site-configuratie array (uit config.json). * @return void */ 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', ]); } /** * Toont en verwerkt het aanmaken van een nieuw thema. * * Biedt een formulier om een nieuw thema aan te maken, eventueel gebaseerd * op een bestaand thema (kopiëren) of met een lege uniforme structuur. Op * succes wordt doorverwezen naar de thema-editor. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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, ]); } /** * Maakt een uniforme thema-structuur aan (theme.json, layouts, partials, assets/scss, etc.). * * @since 2.6.5 * * @param string $themeDir Absoluut pad naar de nieuwe thema-map (al aangemaakt). * @param string $themeName Opgeschoonde thema-naam. * @return void */ 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" . " {% block title %}{{ page_title|default(site_title) }}{% endblock %}\n" . " \n" . "\n\n{% include 'partials/header.twig' %}\n" . "
\n{% block content %}{% endblock %}\n
\n" . "{% include 'partials/footer.twig' %}\n\n\n"); file_put_contents($themeDir . '/full_content.twig', "{% extends 'base.twig' %}\n{% block content %}\n{{ content|raw }}\n{% endblock %}\n"); file_put_contents($themeDir . '/partials/header.twig', "
\n

{{ page_title|default(site_title) }}

\n
\n"); file_put_contents($themeDir . '/partials/footer.twig', "
\n

© {{ \"now\"|date('Y') }} {{ site_title|default('') }}

\n
\n"); // Starter SCSS source file_put_contents($themeDir . '/assets/scss/theme.scss', "/* {$themeName} theme SCSS */\n:root {\n --bs-primary: #0a369d;\n}\nbody {\n font-family: system-ui, sans-serif;\n}\n"); // README per thema file_put_contents($themeDir . '/README.md', "# " . ucfirst($themeName) . " thema\n\nCodePress thema. Zie `theme.json` voor layout mapping en `assets/scss/theme.scss` voor styling.\n\n" . "## Structuur\n- `theme.json` — thema configuratie\n- `base.twig` — hoofd layout\n- `full_content.twig` — volledige-breedte layout\n- `partials/` — header, navigation, footer\n- `assets/scss/theme.scss` — SCSS bron (compileert naar `assets/css_compiled/theme.css`)\n- `assets/css/`, `assets/js/`, `assets/img/`, `assets/fonts/` — extra assets\n"); } /** * Kopieert een directory recursief (gebruikt om een bestaand thema als basis te klonen). * * Slaat css_compiled/ (runtime-artefact) en .git/.gitkeep over. * * @since 2.6.5 * * @param string $src Bron-directory (absoluut). * @param string $dst Doel-directory (absoluut, bestaat al). * @return void */ function copyDirRecursive(string $src, string $dst): void { $realSrc = realpath($src); if (!$realSrc || !is_dir($realSrc)) return; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($realSrc, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { $rel = substr($item->getPathname(), strlen($realSrc) + 1); // Skip runtime artefacts and VCS if (strpos($rel, 'assets/css_compiled/') === 0) continue; $seg0 = explode('/', $rel)[0]; if ($seg0 === '.git') continue; $dest = $dst . '/' . $rel; if ($item->isDir()) { if (!is_dir($dest)) @mkdir($dest, 0755, true); } else { $parent = dirname($dest); if (!is_dir($parent)) @mkdir($parent, 0755, true); @copy($item->getPathname(), $dest); } } } /** * Verwijdert een directory recursief (gebruikt door theme-delete). * * @since 2.6.5 * * @param string $dir Te verwijderen directory (absoluut). * @return void */ function removeDirRecursive(string $dir): void { if (!is_dir($dir)) return; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $item) { if ($item->isDir()) { @rmdir($item->getPathname()); } else { @unlink($item->getPathname()); } } @rmdir($dir); } /** * Toont en verwerkt het bewerken van bestanden binnen een thema. * * Spiegelt handlePluginsEdit maar dan voor themes/. Ondersteunt: * .twig, .json, .scss, .css, .js, .html, .md, .php. Path-traversal bescherming * via realpath + prefix-check. Het default-thema kan wel bewerkt worden (geen * protected block), maar verwijderen/activeren is geblokkeerd. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleThemeEdit($auth, $config, $twig, $user, $csrf): void { $themesDir = __DIR__ . '/../themes'; $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themeDir = $themesDir . '/' . $theme; $realThemeDir = realpath($themeDir); if (!$realThemeDir || !is_dir($realThemeDir)) { header('Location: /admin/theme'); exit; } // Editable extensions within a theme $editableExts = ['twig', 'json', 'scss', 'css', 'js', 'html', 'md', 'php']; // Resolve the requested file (defaults to theme.json) $defaultFile = 'theme.json'; $relFile = $_GET['file'] ?? $defaultFile; $relFile = str_replace(['../', '..\\', './'], '', $relFile); $relFile = ltrim($relFile, '/'); $fullPath = $realThemeDir . '/' . $relFile; $realPath = realpath($fullPath); // Validate path stays within the theme dir if ($realPath === false || strpos($realPath, $realThemeDir) !== 0) { $relFile = $defaultFile; $realPath = realpath($realThemeDir . '/' . $defaultFile); } // Optional selected directory (?dir=). A directory is selected when the user // clicks a folder in the tree; it drives the map-detail pane. $selectedDir = $_GET['dir'] ?? ''; $selectedDir = str_replace(['../', '..\\', './'], '', $selectedDir); $selectedDir = ltrim($selectedDir, '/'); foreach (explode('/', $selectedDir) as $seg) { if ($seg === '..' || $seg === '.') { $selectedDir = ''; break; } } if ($selectedDir !== '') { $realSelectedDir = realpath($realThemeDir . '/' . $selectedDir); if ($realSelectedDir === false || !is_dir($realSelectedDir) || strpos($realSelectedDir, $realThemeDir) !== 0) { $selectedDir = ''; } } $selectedDirName = $selectedDir !== '' ? basename($selectedDir) : ''; $selectedDirIsRoot = $selectedDir === ''; $selectedDirRealPath = $selectedDir !== '' ? realpath($realThemeDir . '/' . $selectedDir) : $realThemeDir; $selectedDirCounts = ['files' => 0, 'dirs' => 0]; if ($selectedDirRealPath && is_dir($selectedDirRealPath)) { foreach (scandir($selectedDirRealPath) as $e) { if ($e === '.' || $e === '..') continue; if ($e[0] === '.') continue; if (is_dir($selectedDirRealPath . '/' . $e)) $selectedDirCounts['dirs']++; else $selectedDirCounts['files']++; } } $message = ''; $messageType = ''; // Carry over flash messages from redirect (upload/delete/move handlers) if (isset($_GET['msg']) && is_string($_GET['msg']) && $_GET['msg'] !== '') { $message = $_GET['msg']; $messageType = $_GET['msgtype'] ?? 'info'; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } elseif (($_POST['action'] ?? '') === 'new_file') { $newName = trim($_POST['new_filename'] ?? ''); $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; } } if ($newName === '' || $newName === '.' || $traversal) { $message = 'Ongeldige bestandsnaam.'; $messageType = 'danger'; } else { $newFullPath = $realThemeDir . '/' . $newName; $parentPath = dirname($newFullPath); if (!is_dir($parentPath)) { @mkdir($parentPath, 0755, true); } $parentReal = realpath($parentPath); if ($parentReal === false || strpos($parentReal, $realThemeDir) !== 0) { $message = 'Ongeldig pad.'; $messageType = 'danger'; } else { $newReal = $parentReal . '/' . basename($newFullPath); if (file_exists($newReal)) { $message = 'Bestand bestaat al.'; $messageType = 'danger'; } else { $ext = strtolower(pathinfo($newName, PATHINFO_EXTENSION)); $stub = ''; if ($ext === 'php') { $stub = "render('pages/theme-edit.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'themeName' => $theme, 'themeDir' => $themeDir, 'relFile' => $relFile, 'fileName' => basename($relFile), 'fileExt' => $fileExt, 'fileContent' => $fileContent, 'isEditable' => $isEditable, 'files' => $files, 'editableExts' => $editableExts, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => $isEditable, 'mediaThemeName' => $theme, 'hasScss' => is_file($realThemeDir . '/assets/scss/theme.scss'), 'scssCompiled' => (is_file($realThemeDir . '/assets/scss/theme.scss') && is_file($realThemeDir . '/assets/css_compiled/theme.css') && filemtime($realThemeDir . '/assets/css_compiled/theme.css') >= filemtime($realThemeDir . '/assets/scss/theme.scss')), 'selectedDir' => $selectedDir, 'selectedDirName' => $selectedDirName, 'selectedDirIsRoot' => $selectedDirIsRoot, 'selectedDirCounts' => $selectedDirCounts, 'showMapPaneel' => $showMapPaneel, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het uploaden van bestanden in de assets/ van een thema. * * Spiegelt handlePluginsFileUpload. Toegestane types: afbeeldingen, video, * audio, pdf, css, scss, js, json, html, md, twig, fonts. Bevat path-traversal * bescherming via realpath + prefix-check op de thema assets-map. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleThemeFileUpload($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/theme'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/theme'); exit; } $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; $assetsRoot = realpath(rtrim($themesDir, '/') . '/' . $theme . '/assets'); if (!$assetsRoot || !is_dir($assetsRoot)) { // assets dir doesn't exist yet — create it $assetsNew = rtrim($themesDir, '/') . '/' . $theme . '/assets'; if (!@mkdir($assetsNew, 0755, true)) { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } $assetsRoot = realpath($assetsNew); } if (!$assetsRoot || !is_dir($assetsRoot)) { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } $subdir = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $subdir = ltrim($subdir, '/'); $targetDir = $assetsRoot . '/' . $subdir; $realTarget = realpath($targetDir); if ($realTarget === false) { if (!@mkdir($targetDir, 0755, true)) { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } $realTarget = realpath($targetDir); } if (!$realTarget || strpos($realTarget, $assetsRoot) !== 0 || !is_dir($realTarget)) { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } $allowedExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'zip', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'mov', 'avi', 'css', 'scss', 'js', 'json', 'html', 'md', 'twig', 'woff', 'woff2', 'ttf', 'eot']; $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 ' . $theme . '/assets/' . ($subdir ? $subdir . '/' : '') . ' (' . $uploaded . ' bestanden)'); } if (!empty($errors)) { $msg[] = 'Fouten: ' . implode(', ', $errors); $type = $type === 'success' ? 'success' : 'danger'; } $redir = '/admin/theme-edit?theme=' . urlencode($theme); if (!empty($msg)) { $redir .= '&msg=' . urlencode(implode(' ', $msg)) . '&msgtype=' . urlencode($type); } header('Location: ' . $redir); exit; } /** * Verwerkt het verwijderen van een enkel bestand binnen een thema-map. * * Weigert mappen, dotfiles, theme.json en paden die buiten de thema-map * vallen. Na verwijderen wordt terugverwezen naar theme.json in de editor. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleThemeFileDelete($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/theme'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/theme'); exit; } $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; $realThemeDir = realpath(rtrim($themesDir, '/') . '/' . $theme); if (!$realThemeDir || !is_dir($realThemeDir)) { header('Location: /admin/theme'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } } $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } // Protect theme.json from deletion if ($relFile === 'theme.json') { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('theme.json kan niet verwijderd worden.') . '&msgtype=danger'); exit; } $fullPath = $realThemeDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realThemeDir) !== 0 || !is_file($realPath)) { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } $deleted = @unlink($realPath); if ($deleted) { adminLog($config, 'info', $user['username'] . ' verwijderde ' . $theme . '/' . $relFile); $msg = 'Bestand verwijderd.'; $type = 'success'; } else { $err = error_get_last(); $msg = 'Verwijderen mislukt: ' . ($err['message'] ?? 'onbekend'); $type = 'danger'; } $redir = '/admin/theme-edit?theme=' . urlencode($theme) . '&file=' . urlencode('theme.json'); $redir .= '&msg=' . urlencode($msg) . '&msgtype=' . urlencode($type); header('Location: ' . $redir); exit; } /** * Verwerkt het verplaatsen van een bestand binnen een thema naar een andere map. * * Spiegelt handlePluginsFileMove. Toont een verplaats-formulier (GET) met een * dropdown van beschikbare mappen en voert de hernoemactie (move) uit op POST. * theme.json kan niet verplaatst worden. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleThemeFileMove($auth, $config, $twig, $user, $csrf): void { $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['theme'] ?? $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; $realThemeDir = realpath(rtrim($themesDir, '/') . '/' . $theme); if (!$realThemeDir || !is_dir($realThemeDir)) { header('Location: /admin/theme'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_GET['file'] ?? $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } } $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } if ($relFile === 'theme.json') { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('theme.json kan niet verplaatst worden.') . '&msgtype=danger'); exit; } $fullPath = $realThemeDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realThemeDir) !== 0 || !is_file($realPath)) { header('Location: /admin/theme-edit?theme=' . urlencode($theme)); exit; } $message = ''; $messageType = ''; $directories = ['']; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($realThemeDir, 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, $realThemeDir) !== 0) continue; $relDir = ltrim(substr($realDir, strlen($realThemeDir) + 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 === '' ? $realThemeDir : $realThemeDir . '/' . $dest; $realDestDir = realpath($destDir); if (!$realDestDir || !is_dir($realDestDir) || strpos($realDestDir, $realThemeDir) !== 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 ' . $theme . '/' . $relFile . ' naar ' . $theme . '/' . ($dest ? $dest . '/' : '') . basename($relFile)); $newRel = $dest === '' ? basename($relFile) : $dest . '/' . basename($relFile); header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&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/theme-move-form.twig', [ 'user' => $user, 'route' => 'theme-file-move', 'csrf_token' => $csrf, 'themeName' => $theme, 'relFile' => $relFile, 'itemName' => basename($relFile), 'itemDir' => trim(dirname($relFile), '.\\/'), 'directories' => $directories, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het activeren van een thema: zet active_theme in config.json (POST). * * Verwacht een geldige CSRF-token en een `theme` POST-veld. Het thema moet * bestaan. Na activeren wordt terugverwezen naar het thema-overzicht. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (config_json). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleThemeActivate($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/theme'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/theme'); exit; } $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; if (!is_dir($themesDir . '/' . $theme)) { header('Location: /admin/theme?msg=' . urlencode('Thema bestaat niet.') . '&msgtype=danger'); exit; } $configFile = $config['config_json']; $siteConfig = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : []; $siteConfig['active_theme'] = $theme; file_put_contents($configFile, json_encode($siteConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); adminLog($config, 'info', $user['username'] . ' activeerde thema ' . $theme); header('Location: /admin/theme?msg=' . urlencode('Thema "' . htmlspecialchars($theme) . '" geactiveerd.') . '&msgtype=success'); exit; } /** * Verwerkt het verwijderen van een thema (POST). * * Alleen niet-actieve, niet-default thema's mogen verwijderd worden. Na * verwijderen wordt terugverwezen naar het thema-overzicht met een melding. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (config_json). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleThemeDelete($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/theme'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/theme'); exit; } $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '' || $theme === 'default') { adminLog($config, 'warning', $user['username'] . ' probeerde default thema te verwijderen'); header('Location: /admin/theme?msg=' . urlencode('Default thema kan niet verwijderd worden.') . '&msgtype=danger'); exit; } $configFile = $config['config_json']; $siteConfig = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : []; if (($siteConfig['active_theme'] ?? 'default') === $theme) { header('Location: /admin/theme?msg=' . urlencode('Actief thema kan niet verwijderd worden. Activeer eerst een ander thema.') . '&msgtype=danger'); exit; } $themesDir = __DIR__ . '/../themes'; $themeDir = $themesDir . '/' . $theme; $realThemeDir = realpath($themeDir); $realThemesRoot = realpath($themesDir); if (!$realThemeDir || !$realThemesRoot || strpos($realThemeDir, $realThemesRoot) !== 0 || !is_dir($realThemeDir)) { header('Location: /admin/theme?msg=' . urlencode('Thema bestaat niet.') . '&msgtype=danger'); exit; } removeDirRecursive($realThemeDir); adminLog($config, 'info', $user['username'] . ' verwijderde thema ' . $theme); header('Location: /admin/theme?msg=' . urlencode('Thema "' . htmlspecialchars($theme) . '" verwijderd.') . '&msgtype=success'); exit; } /** * Verwerkt SCSS-compilatie voor een thema via ThemeManager::compileCss(true) (POST). * * Verwacht een geldige CSRF-token en een `theme` POST-veld. Na compilatie * wordt terugverwezen naar het thema-overzicht met een statusmelding. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleThemeScss($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/theme'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/theme'); exit; } $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; if (!is_dir($themesDir . '/' . $theme)) { header('Location: /admin/theme?msg=' . urlencode('Thema bestaat niet.') . '&msgtype=danger'); exit; } // Build a minimal config to feed ThemeManager $themeConfig = ['active_theme' => $theme, 'theme_dir' => $themesDir . '/' . $theme, 'theme' => []]; $themeJsonFile = $themesDir . '/' . $theme . '/theme.json'; if (is_file($themeJsonFile)) { $themeConfig['theme'] = json_decode(file_get_contents($themeJsonFile), true) ?? []; } $msg = ''; $type = 'info'; if (!class_exists('ThemeManager')) { require_once __DIR__ . '/../cms/core/class/ThemeManager.php'; } try { $tm = new ThemeManager($themeConfig); $result = $tm->compileCss(true); if ($result !== null) { $msg = 'SCSS gecompileerd voor ' . htmlspecialchars($theme) . '.'; $type = 'success'; adminLog($config, 'info', $user['username'] . ' compileerde SCSS voor ' . $theme); } else { $msg = 'SCSS compilatie mislukt voor ' . htmlspecialchars($theme) . '. Controleer of assets/scss/theme.scss bestaat.'; $type = 'danger'; adminLog($config, 'error', $user['username'] . ' SCSS compilatie mislukt voor ' . $theme); } } catch (\Throwable $e) { $msg = 'SCSS compilatie fout: ' . htmlspecialchars($e->getMessage()); $type = 'danger'; adminLog($config, 'error', $user['username'] . ' SCSS compilatie fout voor ' . $theme . ': ' . $e->getMessage()); } $redir = '/admin/theme?msg=' . urlencode($msg) . '&msgtype=' . urlencode($type); header('Location: ' . $redir); exit; } /** * Toont het plugin-overzicht met alle geïnstalleerde plugins. * * Toont per plugin de naam, ingeschakelde status, of deze beschermd is en het * type (content/system). Plugin-type wordt bepaald uit plugin.json of de * plugin PHP-bron. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir, config_json). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handlePlugins($auth, $config, $twig, $user, $csrf): void { $pluginsDir = $config['plugins_dir']; $configFile = $config['config_json']; $siteConfig = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : []; $enabledPlugins = $siteConfig['enabled_plugins'] ?? []; $plugins = []; foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) { $pluginName = basename($pluginDir); $pluginJson = $pluginDir . '/plugin.json'; $pluginData = [ 'name' => $pluginName, 'enabled' => in_array($pluginName, $enabledPlugins) || isProtectedPlugin($pluginName), 'protected' => isProtectedPlugin($pluginName), 'type' => 'content', ]; if (file_exists($pluginJson)) { $data = json_decode(file_get_contents($pluginJson), true); $pluginData = array_merge($pluginData, $data); } $pluginFile = $pluginDir . '/' . $pluginName . '.php'; if (file_exists($pluginFile) && !isset($pluginData['type'])) { $source = file_get_contents($pluginFile); if (preg_match("/'type'\s*=>\s*'([^']+)'/", $source, $m)) { $pluginData['type'] = $m[1]; } } $plugins[] = $pluginData; } $route = 'plugins'; echo $twig->render('pages/plugins.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'plugins' => $plugins, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => '', 'message_type' => 'info', ]); } /** * Toont en verwerkt het aanmaken van een nieuwe plugin. * * Biedt een formulier om een nieuwe plugin-map aan te maken met een * plugin.json en een PHP-stub. Op succes wordt doorverwezen naar de * plugin-editor. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handlePluginsNew($auth, $config, $twig, $user, $csrf): void { $message = ''; $messageType = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $pluginName = trim($_POST['name'] ?? ''); if (!empty($pluginName)) { $pluginName = preg_replace('/[^a-zA-Z0-9_-]/', '-', $pluginName); $pluginsDir = $config['plugins_dir']; $newPluginDir = $pluginsDir . $pluginName; if (!file_exists($newPluginDir)) { @mkdir($newPluginDir, 0755, true); $pluginJson = [ 'name' => ucfirst($pluginName), 'version' => '1.0.0', 'author' => $user['username'], ]; file_put_contents($newPluginDir . '/plugin.json', json_encode($pluginJson, JSON_PRETTY_PRINT)); file_put_contents($newPluginDir . '/' . $pluginName . '.php', "render('pages/plugins-new.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Toont en verwerkt het bewerken van bestanden binnen een plugin. * * Toont een geneste bestandsboom zijbalk + CodeMirror editor voor .php/.json/ * .md/.html/.css/.scss/.js bestanden. Ondersteunt: nieuw bestand, opslaan, * map aanmaken. Beschermde plugins kunnen niet bewerkt worden. Bevat * path-traversal bescherming via realpath + prefix-check. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handlePluginsEdit($auth, $config, $twig, $user, $csrf): void { $pluginsDir = $config['plugins_dir']; $plugin = $_GET['plugin'] ?? ''; $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $plugin); // Block editing protected plugins if (isProtectedPlugin($plugin)) { adminLog($config, 'warning', $user['username'] . ' probeerde essentiële plugin ' . $plugin . ' te bewerken'); header('Location: /admin/plugins'); exit; } $pluginDir = $pluginsDir . $plugin; $realPluginDir = realpath($pluginDir); // Plugin must exist as a directory if (!$realPluginDir || !is_dir($realPluginDir)) { header('Location: /admin/plugins'); exit; } // Editable file extensions within a plugin $editableExts = ['php', 'json', 'md', 'html', 'css', 'scss', 'js']; // Resolve the requested file (defaults to .php) $defaultFile = $plugin . '.php'; $relFile = $_GET['file'] ?? $defaultFile; // Normalise: strip leading slashes, prevent path traversal $relFile = str_replace(['../', '..\\', './'], '', $relFile); $relFile = ltrim($relFile, '/'); $fullPath = $realPluginDir . '/' . $relFile; $realPath = realpath($fullPath); // Validate path stays within the plugin dir if ($realPath === false || strpos($realPath, $realPluginDir) !== 0) { // Fall back to default file $relFile = $defaultFile; $realPath = realpath($realPluginDir . '/' . $defaultFile); } // Optional selected directory (?dir=). A directory is selected when the user // clicks a folder in the tree; it drives the map-detail pane. $selectedDir = $_GET['dir'] ?? ''; $selectedDir = str_replace(['../', '..\\', './'], '', $selectedDir); $selectedDir = ltrim($selectedDir, '/'); foreach (explode('/', $selectedDir) as $seg) { if ($seg === '..' || $seg === '.') { $selectedDir = ''; break; } } if ($selectedDir !== '') { $realSelectedDir = realpath($realPluginDir . '/' . $selectedDir); if ($realSelectedDir === false || !is_dir($realSelectedDir) || strpos($realSelectedDir, $realPluginDir) !== 0) { $selectedDir = ''; } } $selectedDirName = $selectedDir !== '' ? basename($selectedDir) : ''; $selectedDirIsRoot = $selectedDir === ''; $selectedDirRealPath = $selectedDir !== '' ? realpath($realPluginDir . '/' . $selectedDir) : $realPluginDir; $selectedDirCounts = ['files' => 0, 'dirs' => 0]; if ($selectedDirRealPath && is_dir($selectedDirRealPath)) { foreach (scandir($selectedDirRealPath) as $e) { if ($e === '.' || $e === '..') continue; if ($e[0] === '.') continue; if (is_dir($selectedDirRealPath . '/' . $e)) $selectedDirCounts['dirs']++; else $selectedDirCounts['files']++; } } $message = ''; $messageType = ''; // Carry over flash messages from redirect (upload/delete handlers) if (isset($_GET['msg']) && is_string($_GET['msg']) && $_GET['msg'] !== '') { $message = $_GET['msg']; $messageType = $_GET['msgtype'] ?? 'info'; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } elseif (($_POST['action'] ?? '') === 'new_file') { // Create a new file inside the plugin dir $newName = trim($_POST['new_filename'] ?? ''); $newName = preg_replace('/[^a-zA-Z0-9._\-\/]/', '', $newName); $newName = ltrim($newName, '/'); // Strip any traversal attempts $newName = str_replace(['../', '..\\', './'], '', $newName); // Reject any path segment that is '..' (extra traversal guard) $segments = explode('/', $newName); $traversal = false; foreach ($segments as $seg) { if ($seg === '..' || $seg === '.') { $traversal = true; break; } } if ($newName === '' || $newName === '.' || $traversal) { $message = 'Ongeldige bestandsnaam.'; $messageType = 'danger'; } else { $newFullPath = $realPluginDir . '/' . $newName; $parentPath = dirname($newFullPath); // Create parent dirs if needed (e.g. assets/css/) if (!is_dir($parentPath)) { @mkdir($parentPath, 0755, true); } $parentReal = realpath($parentPath); if ($parentReal === false || strpos($parentReal, $realPluginDir) !== 0) { $message = 'Ongeldig pad.'; $messageType = 'danger'; } else { $newReal = $parentReal . '/' . basename($newFullPath); if (file_exists($newReal)) { $message = 'Bestand bestaat al.'; $messageType = 'danger'; } else { $ext = strtolower(pathinfo($newName, PATHINFO_EXTENSION)); $stub = ''; if ($ext === 'php') { $stub = "render('pages/plugins-edit.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'pluginName' => $plugin, 'pluginDir' => $pluginDir, 'relFile' => $relFile, 'fileName' => basename($relFile), 'fileExt' => $fileExt, 'fileContent' => $fileContent, 'isEditable' => $isEditable, 'files' => $files, 'editableExts' => $editableExts, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => $isEditable, 'mediaPluginName' => $plugin, 'selectedDir' => $selectedDir, 'selectedDirName' => $selectedDirName, 'selectedDirIsRoot' => $selectedDirIsRoot, 'selectedDirCounts' => $selectedDirCounts, 'showMapPaneel' => $showMapPaneel, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het uploaden van bestanden in de assets/ van een plugin. * * Bestanden landen in plugins//assets//. De doelmap * komt uit ?dir= (standaard de assets-root). Alleen media- en asset-types * zijn toegestaan (afbeeldingen, video, audio, pdf, css, scss, js). * * Path-traversal bescherming: plugin-naam wordt opgeschoond en de opgeloste * doelmap moet binnen de echte plugin assets-map liggen. Beschermde plugins * worden geblokkeerd. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handlePluginsFileUpload($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/plugins'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/plugins'); exit; } $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['plugin'] ?? ''); if (isProtectedPlugin($plugin)) { adminLog($config, 'warning', $user['username'] . ' probeerde upload in beschermde plugin ' . $plugin); header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $pluginsDir = $config['plugins_dir']; $assetsRoot = realpath(rtrim($pluginsDir, '/') . '/' . $plugin . '/assets'); if (!$assetsRoot || !is_dir($assetsRoot)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } // Optional subdir within assets/ (e.g. "css" or "images") $subdir = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $subdir = ltrim($subdir, '/'); $targetDir = $assetsRoot . '/' . $subdir; $realTarget = realpath($targetDir); // If the subdir does not exist yet, create it and re-resolve if ($realTarget === false) { if (!@mkdir($targetDir, 0755, true)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $realTarget = realpath($targetDir); } if (!$realTarget || strpos($realTarget, $assetsRoot) !== 0 || !is_dir($realTarget)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $allowedExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'zip', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'mov', 'avi', '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 ' . $plugin . '/assets/' . ($subdir ? $subdir . '/' : '') . ' (' . $uploaded . ' bestanden)'); } if (!empty($errors)) { $msg[] = 'Fouten: ' . implode(', ', $errors); $type = $type === 'success' ? 'success' : 'danger'; } $redir = '/admin/plugins-edit?plugin=' . urlencode($plugin); if (!empty($msg)) { $redir .= '&msg=' . urlencode(implode(' ', $msg)) . '&msgtype=' . urlencode($type); } header('Location: ' . $redir); exit; } /** * Verwerkt het verwijderen van een enkel bestand binnen een plugin-map. * * Weigert mappen, dotfiles en paden die buiten de plugin-map vallen. Na * verwijderen wordt terugverwezen naar het hoofdpluginbestand. Beschermde * plugins worden geblokkeerd. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handlePluginsFileDelete($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/plugins'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/plugins'); exit; } $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['plugin'] ?? ''); if (isProtectedPlugin($plugin)) { adminLog($config, 'warning', $user['username'] . ' probeerde bestand te verwijderen in beschermde plugin ' . $plugin); header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $pluginsDir = $config['plugins_dir']; $realPluginDir = realpath(rtrim($pluginsDir, '/') . '/' . $plugin); if (!$realPluginDir || !is_dir($realPluginDir)) { header('Location: /admin/plugins'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); // Reject path segments that are '..' or '.' foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } } // Refuse dotfiles $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $fullPath = $realPluginDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realPluginDir) !== 0 || !is_file($realPath)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $deleted = @unlink($realPath); if ($deleted) { adminLog($config, 'info', $user['username'] . ' verwijderde ' . $plugin . '/' . $relFile); $msg = 'Bestand verwijderd.'; $type = 'success'; } else { $err = error_get_last(); $msg = 'Verwijderen mislukt: ' . ($err['message'] ?? 'onbekend'); $type = 'danger'; } // After deletion, fall back to the main plugin file $redir = '/admin/plugins-edit?plugin=' . urlencode($plugin) . '&file=' . urlencode($plugin . '.php'); $redir .= '&msg=' . urlencode($msg) . '&msgtype=' . urlencode($type); header('Location: ' . $redir); exit; } /** * Verwerkt het verplaatsen van een bestand binnen een plugin naar een andere map. * * Toont een verplaats-formulier (GET) met een dropdown van beschikbare mappen * en voert de hernoemactie (move) uit op POST. Path-traversal bescherming: * bron en doel moeten beide binnen de echte plugin-map vallen. Beschermde * plugins worden geblokkeerd. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handlePluginsFileMove($auth, $config, $twig, $user, $csrf): void { $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['plugin'] ?? $_POST['plugin'] ?? ''); if (isProtectedPlugin($plugin)) { adminLog($config, 'warning', $user['username'] . ' probeerde bestand te verplaatsen in beschermde plugin ' . $plugin); header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $pluginsDir = $config['plugins_dir']; $realPluginDir = realpath(rtrim($pluginsDir, '/') . '/' . $plugin); if (!$realPluginDir || !is_dir($realPluginDir)) { header('Location: /admin/plugins'); exit; } $relFile = str_replace(['../', '..\\', './'], '', $_GET['file'] ?? $_POST['file'] ?? ''); $relFile = ltrim($relFile, '/'); foreach (explode('/', $relFile) as $seg) { if ($seg === '..' || $seg === '.') { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } } $base = basename($relFile); if ($base === '' || $base[0] === '.') { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $fullPath = $realPluginDir . '/' . $relFile; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realPluginDir) !== 0 || !is_file($realPath)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $message = ''; $messageType = ''; // Collect all directories within the plugin (as relative paths), excluding // the directory the file currently lives in. The plugin root ("") is always // offered as a destination. $directories = ['']; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($realPluginDir, 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, $realPluginDir) !== 0) continue; $relDir = ltrim(substr($realDir, strlen($realPluginDir) + 1), '/\\'); $relDir = str_replace('\\', '/', $relDir); // Skip the source file's own directory (no-op move) if ($relDir === $currentDir) continue; // Skip dotfile dirs 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 === '' ? $realPluginDir : $realPluginDir . '/' . $dest; $realDestDir = realpath($destDir); if (!$realDestDir || !is_dir($realDestDir) || strpos($realDestDir, $realPluginDir) !== 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 ' . $plugin . '/' . $relFile . ' naar ' . $plugin . '/' . ($dest ? $dest . '/' : '') . basename($relFile)); $newRel = $dest === '' ? basename($relFile) : $dest . '/' . basename($relFile); header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&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/plugins-move-form.twig', [ 'user' => $user, 'route' => 'plugins-file-move', 'csrf_token' => $csrf, 'pluginName' => $plugin, 'relFile' => $relFile, 'itemName' => basename($relFile), 'itemDir' => trim(dirname($relFile), '.\\/'), 'directories' => $directories, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het aanmaken van een map binnen een thema vanuit de editor-zijbalk. * * Spiegelt handleContentDirCreateIn. Alleen POST met een geldige CSRF-token. * Na aanmaken wordt doorverwezen naar de nieuwe map in de thema-editor. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleThemeDirCreateIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/theme'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/theme'); exit; } $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; $realThemeDir = realpath(rtrim($themesDir, '/') . '/' . $theme); if (!$realThemeDir || !is_dir($realThemeDir)) { header('Location: /admin/theme'); 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/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Ongeldige mapnaam.') . '&msgtype=danger'); exit; } $newPath = $realThemeDir . '/' . ($inDir ? $inDir . '/' : '') . $dirname; if (file_exists($newPath)) { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Map bestaat al.') . '&msgtype=danger'); exit; } if (@mkdir($newPath, 0755, true)) { adminLog($config, 'info', $user['username'] . ' creëerde thema map ' . $theme . '/' . ($inDir ? $inDir . '/' : '') . $dirname); $dir = $inDir ? $inDir . '/' . $dirname : $dirname; header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&dir=' . urlencode($dir) . '&msg=' . urlencode('Map aangemaakt.') . '&msgtype=success'); } else { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Map aanmaken mislukt.') . '&msgtype=danger'); } exit; } /** * Verwerkt het hernoemen van een map binnen een thema vanuit de editor-zijbalk. * * Spiegelt handleContentDirRenameIn. Toont een hernoem-formulier (GET) en * voert de hernoemactie uit op POST. Na succes wordt doorverwezen naar de * hernoemde map. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleThemeDirRenameIn($auth, $config, $twig, $user, $csrf): void { $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['theme'] ?? $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; $realThemeDir = realpath(rtrim($themesDir, '/') . '/' . $theme); if (!$realThemeDir || !is_dir($realThemeDir)) { header('Location: /admin/theme'); exit; } $dirRel = str_replace(['../', '..\\', './'], '', $_GET['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { $dirRel = ''; break; } } $message = ''; $messageType = ''; if ($dirRel !== '') { $fullPath = $realThemeDir . '/' . $dirRel; $realDir = realpath($fullPath); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realThemeDir) !== 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 mapnaam.'; $messageType = 'danger'; } else { $fullPath = $realThemeDir . '/' . $dirRel; $realDir = realpath($fullPath); $parentReal = realpath(dirname($fullPath)); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realThemeDir) !== 0 || !$parentReal || strpos($parentReal, $realThemeDir) !== 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 thema map ' . $theme . '/' . $dirRel . ' naar ' . $newName); $newDir = trim(dirname($dirRel), '.\\/') . ($dirRel && strpos($dirRel, '/') !== false ? '/' : '') . $newName; $newDir = ltrim($newDir, '/'); header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&dir=' . urlencode($newDir) . '&msg=' . urlencode('Map hernoemd.') . '&msgtype=success'); exit; } else { $message = 'Hernoemen mislukt.'; $messageType = 'danger'; } } } } } echo $twig->render('pages/theme-dir-rename-form.twig', [ 'user' => $user, 'route' => 'theme-dir-rename-in', 'csrf_token' => $csrf, 'themeName' => $theme, 'dirRel' => $dirRel, 'dirName' => basename($dirRel), 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het verwijderen van een map binnen een thema vanuit de editor-zijbalk. * * Spiegelt handleContentDirDeleteIn. Alleen lege mappen mogen verwijderd * worden. Alleen POST met een geldige CSRF-token. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handleThemeDirDeleteIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/theme'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/theme'); exit; } $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '') { header('Location: /admin/theme'); exit; } $themesDir = __DIR__ . '/../themes'; $realThemeDir = realpath(rtrim($themesDir, '/') . '/' . $theme); if (!$realThemeDir || !is_dir($realThemeDir)) { header('Location: /admin/theme'); exit; } $dirRel = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { $dirRel = ''; break; } } if ($dirRel === '') { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Ongeldige map.') . '&msgtype=danger'); exit; } $fullPath = $realThemeDir . '/' . $dirRel; $realDir = realpath($fullPath); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realThemeDir) !== 0) { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Map niet gevonden.') . '&msgtype=danger'); exit; } $entries = array_diff(scandir($realDir), ['.', '..']); $isEmpty = true; foreach ($entries as $e) { if ($e[0] !== '.') { $isEmpty = false; break; } } if (!$isEmpty) { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Map moet leeg zijn.') . '&msgtype=danger'); exit; } if (@rmdir($realDir)) { adminLog($config, 'info', $user['username'] . ' verwijderde thema map ' . $theme . '/' . $dirRel); header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Map verwijderd.') . '&msgtype=success'); } else { header('Location: /admin/theme-edit?theme=' . urlencode($theme) . '&msg=' . urlencode('Map verwijderen mislukt.') . '&msgtype=danger'); } exit; } /** * Verwerkt het aanmaken van een map binnen een plugin vanuit de editor-zijbalk. * * Spiegelt handleContentDirCreateIn. Beschermde plugins worden geblokkeerd. * Alleen POST met een geldige CSRF-token. Na aanmaken wordt doorverwezen naar * de nieuwe map in de plugin-editor. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handlePluginsDirCreateIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/plugins'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/plugins'); exit; } $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['plugin'] ?? ''); if (isProtectedPlugin($plugin)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $pluginsDir = $config['plugins_dir']; $realPluginDir = realpath(rtrim($pluginsDir, '/') . '/' . $plugin); if (!$realPluginDir || !is_dir($realPluginDir)) { header('Location: /admin/plugins'); 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/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Ongeldige mapnaam.') . '&msgtype=danger'); exit; } $newPath = $realPluginDir . '/' . ($inDir ? $inDir . '/' : '') . $dirname; if (file_exists($newPath)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Map bestaat al.') . '&msgtype=danger'); exit; } if (@mkdir($newPath, 0755, true)) { adminLog($config, 'info', $user['username'] . ' creëerde plugin map ' . $plugin . '/' . ($inDir ? $inDir . '/' : '') . $dirname); $dir = $inDir ? $inDir . '/' . $dirname : $dirname; header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&dir=' . urlencode($dir) . '&msg=' . urlencode('Map aangemaakt.') . '&msgtype=success'); } else { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Map aanmaken mislukt.') . '&msgtype=danger'); } exit; } /** * Verwerkt het hernoemen van een map binnen een plugin vanuit de editor-zijbalk. * * Spiegelt handleContentDirRenameIn. Beschermde plugins worden geblokkeerd. * Toont een hernoem-formulier (GET) en voert de hernoemactie uit op POST. Na * succes wordt doorverwezen naar de hernoemde map. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handlePluginsDirRenameIn($auth, $config, $twig, $user, $csrf): void { $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['plugin'] ?? $_POST['plugin'] ?? ''); if (isProtectedPlugin($plugin)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $pluginsDir = $config['plugins_dir']; $realPluginDir = realpath(rtrim($pluginsDir, '/') . '/' . $plugin); if (!$realPluginDir || !is_dir($realPluginDir)) { header('Location: /admin/plugins'); exit; } $dirRel = str_replace(['../', '..\\', './'], '', $_GET['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { $dirRel = ''; break; } } $message = ''; $messageType = ''; if ($dirRel !== '') { $fullPath = $realPluginDir . '/' . $dirRel; $realDir = realpath($fullPath); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realPluginDir) !== 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 mapnaam.'; $messageType = 'danger'; } else { $fullPath = $realPluginDir . '/' . $dirRel; $realDir = realpath($fullPath); $parentReal = realpath(dirname($fullPath)); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realPluginDir) !== 0 || !$parentReal || strpos($parentReal, $realPluginDir) !== 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 plugin map ' . $plugin . '/' . $dirRel . ' naar ' . $newName); $newDir = trim(dirname($dirRel), '.\\/') . ($dirRel && strpos($dirRel, '/') !== false ? '/' : '') . $newName; $newDir = ltrim($newDir, '/'); header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&dir=' . urlencode($newDir) . '&msg=' . urlencode('Map hernoemd.') . '&msgtype=success'); exit; } else { $message = 'Hernoemen mislukt.'; $messageType = 'danger'; } } } } } echo $twig->render('pages/plugins-dir-rename-form.twig', [ 'user' => $user, 'route' => 'plugins-dir-rename-in', 'csrf_token' => $csrf, 'pluginName' => $plugin, 'dirRel' => $dirRel, 'dirName' => basename($dirRel), 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Verwerkt het verwijderen van een map binnen een plugin vanuit de editor-zijbalk. * * Spiegelt handleContentDirDeleteIn. Alleen lege mappen mogen verwijderd * worden. Beschermde plugins worden geblokkeerd. Alleen POST met een geldige * CSRF-token. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void */ function handlePluginsDirDeleteIn($auth, $config, $user): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/plugins'); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/plugins'); exit; } $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['plugin'] ?? ''); if (isProtectedPlugin($plugin)) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin)); exit; } $pluginsDir = $config['plugins_dir']; $realPluginDir = realpath(rtrim($pluginsDir, '/') . '/' . $plugin); if (!$realPluginDir || !is_dir($realPluginDir)) { header('Location: /admin/plugins'); exit; } $dirRel = str_replace(['../', '..\\', './'], '', $_POST['dir'] ?? ''); $dirRel = ltrim($dirRel, '/'); foreach (explode('/', $dirRel) as $seg) { if ($seg === '..' || $seg === '.') { $dirRel = ''; break; } } if ($dirRel === '') { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Ongeldige map.') . '&msgtype=danger'); exit; } $fullPath = $realPluginDir . '/' . $dirRel; $realDir = realpath($fullPath); if (!$realDir || !is_dir($realDir) || strpos($realDir, $realPluginDir) !== 0) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Map niet gevonden.') . '&msgtype=danger'); exit; } $entries = array_diff(scandir($realDir), ['.', '..']); $isEmpty = true; foreach ($entries as $e) { if ($e[0] !== '.') { $isEmpty = false; break; } } if (!$isEmpty) { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Map moet leeg zijn.') . '&msgtype=danger'); exit; } if (@rmdir($realDir)) { adminLog($config, 'info', $user['username'] . ' verwijderde plugin map ' . $plugin . '/' . $dirRel); header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Map verwijderd.') . '&msgtype=success'); } else { header('Location: /admin/plugins-edit?plugin=' . urlencode($plugin) . '&msg=' . urlencode('Map verwijderen mislukt.') . '&msgtype=danger'); } exit; } /** * Uniforme AJAX move-handler voor de bestandsboom drag-and-drop. * * Accepteert: scope (content|theme|plugin), source (relatief pad) en * destination (relatieve map). Retourneert JSON: {ok, message, newPath?, redirect?}. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, plugins_dir). * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @return void Output wordt als JSON naar de client gestuurd en het script beeindigd. */ function handleTreeMove($auth, $config, $user): void { header('Content-Type: application/json'); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'message' => 'Ongeldige aanvraag.']); exit; } if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { echo json_encode(['ok' => false, 'message' => 'Ongeldige CSRF token.']); exit; } $scope = $_POST['scope'] ?? 'content'; $source = str_replace(['../', '..\\', './'], '', $_POST['source'] ?? ''); $source = ltrim($source, '/'); $dest = str_replace(['../', '..\\', './'], '', $_POST['destination'] ?? ''); $dest = ltrim($dest, '/'); // Validate paths foreach (explode('/', $source) as $seg) { if ($seg === '..' || $seg === '.') { echo json_encode(['ok' => false, 'message' => 'Ongeldig bronpad.']); exit; } } foreach (explode('/', $dest) as $seg) { if ($seg === '..' || $seg === '.') { echo json_encode(['ok' => false, 'message' => 'Ongeldige doelmap.']); exit; } } $base = basename($source); if ($base === '' || $base[0] === '.') { echo json_encode(['ok' => false, 'message' => 'Ongeldige bestandsnaam.']); exit; } if ($scope === 'content') { $realBase = realpath($config['content_dir']); $redirectBase = '/admin/content'; $scopeLabel = 'content'; } elseif ($scope === 'theme') { $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? ''); if ($theme === '') { echo json_encode(['ok' => false, 'message' => 'Ongeldig thema.']); exit; } $realBase = realpath(__DIR__ . '/../themes/' . $theme); $redirectBase = '/admin/theme-edit?theme=' . urlencode($theme); $scopeLabel = $theme; if ($source === 'theme.json') { echo json_encode(['ok' => false, 'message' => 'theme.json kan niet verplaatst worden.']); exit; } } elseif ($scope === 'plugin') { $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['plugin'] ?? ''); if (isProtectedPlugin($plugin)) { echo json_encode(['ok' => false, 'message' => 'Beschermde plugin.']); exit; } $realBase = realpath(rtrim($config['plugins_dir'], '/') . '/' . $plugin); $redirectBase = '/admin/plugins-edit?plugin=' . urlencode($plugin); $scopeLabel = $plugin; } else { echo json_encode(['ok' => false, 'message' => 'Ongeldige scope.']); exit; } if (!$realBase || !is_dir($realBase)) { echo json_encode(['ok' => false, 'message' => 'Basismap niet gevonden.']); exit; } $fullPath = $realBase . '/' . $source; $realPath = realpath($fullPath); if ($realPath === false || strpos($realPath, $realBase) !== 0) { echo json_encode(['ok' => false, 'message' => 'Bronbestand niet gevonden.']); exit; } // Resolve destination dir $destDir = $dest === '' ? $realBase : $realBase . '/' . $dest; $realDestDir = realpath($destDir); if (!$realDestDir || !is_dir($realDestDir) || strpos($realDestDir, $realBase) !== 0) { echo json_encode(['ok' => false, 'message' => 'Doelmap bestaat niet.']); exit; } // Don't move a folder into its own subtree if (is_dir($realPath) && $dest !== '' && strpos($dest, $source . '/') === 0) { echo json_encode(['ok' => false, 'message' => 'Kan een map niet naar zichzelf verplaatsen.']); exit; } $newPath = $realDestDir . '/' . basename($realPath); if (realpath($newPath) === $realPath) { echo json_encode(['ok' => true, 'message' => 'Geen wijziging.', 'newPath' => $source, 'redirect' => $redirectBase . (str_contains($redirectBase, '?') ? '&' : '?') . 'file=' . urlencode($source)]); exit; } if (file_exists($newPath)) { echo json_encode(['ok' => false, 'message' => 'Bestand bestaat al op de bestemming.']); exit; } if (rename($realPath, $newPath)) { $newRel = $dest === '' ? basename($source) : $dest . '/' . basename($source); adminLog($config, 'info', $user['username'] . ' verplaatste ' . $scopeLabel . '/' . $source . ' naar ' . $scopeLabel . '/' . $newRel); $sep = str_contains($redirectBase, '?') ? '&' : '?'; echo json_encode(['ok' => true, 'message' => 'Verplaatst.', 'newPath' => $newRel, 'redirect' => $redirectBase . $sep . 'file=' . urlencode($newRel) . '&msg=' . urlencode('Verplaatst.') . '&msgtype=success']); } else { $err = error_get_last(); echo json_encode(['ok' => false, 'message' => 'Verplaatsen mislukt: ' . ($err['message'] ?? 'onbekend')]); } exit; } /** * Toont en verwerkt de plugin-configuratie pagina. * * Laadt het settings-schema uit plugin.json en de huidige overrides uit * config.json van de plugin. Op POST worden de waarden opgeslagen. Setting- * labels/helpteksten worden via plugin-vertalingen opgelost. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir, config_json). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handlePluginsConfig($auth, $config, $twig, $user, $csrf): void { $pluginsDir = $config['plugins_dir']; $plugin = $_GET['plugin'] ?? ''; $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $plugin); $pluginDir = $pluginsDir . $plugin; $pluginJsonFile = $pluginDir . '/plugin.json'; $configJsonFile = $pluginDir . '/config.json'; $message = ''; $messageType = ''; // Load plugin.json to get the settings schema $pluginJson = []; if (file_exists($pluginJsonFile)) { $pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? []; } $settingsSchema = $pluginJson['settings'] ?? []; // Load current config.json (overrides) $currentConfig = []; if (file_exists($configJsonFile)) { $currentConfig = json_decode(file_get_contents($configJsonFile), true) ?? []; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { // Build new config from POST values based on the schema $newConfig = []; foreach ($settingsSchema as $setting) { $key = $setting['key'] ?? ''; if ($key === '') continue; $type = $setting['type'] ?? 'text'; if ($type === 'checkbox') { $newConfig[$key] = isset($_POST['setting_' . $key]); } elseif ($type === 'number') { $newConfig[$key] = (int)($_POST['setting_' . $key] ?? 0); } elseif ($type === 'multi-select') { $newConfig[$key] = $_POST['setting_' . $key] ?? []; } else { $newConfig[$key] = trim($_POST['setting_' . $key] ?? ''); } } file_put_contents($configJsonFile, json_encode($newConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); adminLog($config, 'info', $user['username'] . ' bewerkte plugin config ' . $plugin); $message = 'Plugin configuratie opgeslagen.'; $messageType = 'success'; // Reload current config so the form shows the new values $currentConfig = $newConfig; } } // Build resolved values: defaults from schema + overrides from config.json $resolvedConfig = []; foreach ($settingsSchema as $setting) { $key = $setting['key'] ?? ''; if ($key === '') continue; $resolvedConfig[$key] = array_key_exists($key, $currentConfig) ? $currentConfig[$key] : ($setting['default'] ?? null); } // Resolve setting labels/help text via plugin translations. // A setting may declare `label_key`/`help_key` pointing at a key in the // plugin's language//admin.php file. If present and resolved, the // translated value is exposed as `resolved_label`/`resolved_help` on the // setting. The Twig template falls back to the raw `label`/`help` from // plugin.json when these are empty. $siteConfigForPluginI18n = file_exists($config['config_json']) ? (json_decode(file_get_contents($config['config_json']), true) ?? []) : []; $pluginAdminLang = $siteConfigForPluginI18n['admin_language'] ?? ($siteConfigForPluginI18n['language']['default'] ?? 'nl'); $pluginSiteDefaultLang = $siteConfigForPluginI18n['language']['default'] ?? 'nl'; $pluginPm = new PluginManager($config['plugins_dir'], [], $pluginSiteDefaultLang); $pluginT = $pluginPm->getPluginTranslations($plugin, $pluginAdminLang, 'admin'); foreach ($settingsSchema as &$setting) { $labelKey = $setting['label_key'] ?? ''; $helpKey = $setting['help_key'] ?? ''; $setting['resolved_label'] = ($labelKey !== '' && isset($pluginT[$labelKey])) ? $pluginT[$labelKey] : ''; $setting['resolved_help'] = ($helpKey !== '' && isset($pluginT[$helpKey])) ? $pluginT[$helpKey] : ''; // Resolve option labels for select/multi-select via option_label_key $optionLabelKey = $setting['option_label_key'] ?? ''; if ($optionLabelKey !== '' && isset($pluginT[$optionLabelKey]) && is_array($pluginT[$optionLabelKey])) { $setting['resolved_options'] = $pluginT[$optionLabelKey]; } else { $setting['resolved_options'] = null; } } unset($setting); $route = 'plugins-config'; echo $twig->render('pages/plugin-config.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'pluginName' => $plugin, 'pluginJson' => $pluginJson, 'settingsSchema' => $settingsSchema, 'resolvedConfig' => $resolvedConfig, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Schakelt een plugin in of uit (POST). * * Zet de plugin in de enabled_plugins-lijst van config.json (aan/uit toggle). * Beschermde plugins kunnen niet uitgeschakeld worden. Verwacht een geldige * CSRF-token en een `plugin` POST-veld. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (config_json). * @return void */ function handlePluginsToggle($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/plugins'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/plugins'); exit; } $plugin = $_POST['plugin'] ?? ''; $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $plugin); $configFile = $config['config_json']; $siteConfig = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : []; $enabledPlugins = $siteConfig['enabled_plugins'] ?? []; $currentlyEnabled = in_array($plugin, $enabledPlugins, true); // Protected/essential plugins mogen niet uitgeschakeld worden (maar wel // aangezet worden als ze per ongeluk uit enabled_plugins zijn geraakt). if (isProtectedPlugin($plugin) && $currentlyEnabled) { adminLog($config, 'warning', $user['username'] . ' probeerde essentiële plugin ' . $plugin . ' te deactiveren'); header('Location: /admin/plugins'); exit; } if ($currentlyEnabled) { $enabledPlugins = array_diff($enabledPlugins, [$plugin]); adminLog($config, 'info', $user['username'] . ' deactiveerde plugin ' . $plugin); } else { $enabledPlugins[] = $plugin; adminLog($config, 'info', $user['username'] . ' activeerde plugin ' . $plugin); } $siteConfig['enabled_plugins'] = array_values($enabledPlugins); file_put_contents($configFile, json_encode($siteConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); header('Location: /admin/plugins'); exit; } /** * Verwijdert een plugin recursief (POST). * * Beschermde plugins kunnen niet verwijderd worden. Verwacht een geldige * CSRF-token en een `plugin` POST-veld. Na verwijderen wordt terugverwezen * naar het plugin-overzicht. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (plugins_dir). * @return void */ function handlePluginsDelete($auth, $config): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: /admin/plugins'); exit; } $user = $auth->getCurrentUser(); if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { header('Location: /admin/plugins'); exit; } $plugin = $_POST['plugin'] ?? ''; $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $plugin); // Block deleting protected plugins if (isProtectedPlugin($plugin)) { adminLog($config, 'warning', $user['username'] . ' probeerde essentiële plugin ' . $plugin . ' te verwijderen'); header('Location: /admin/plugins'); exit; } $pluginsDir = $config['plugins_dir']; $pluginDir = $pluginsDir . $plugin; if (is_dir($pluginDir)) { // Remove plugin directory recursively $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($pluginDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if ($file->isDir()) { rmdir($file->getPathname()); } else { unlink($file->getPathname()); } } rmdir($pluginDir); adminLog($config, 'info', $user['username'] . ' verwijderde plugin ' . $plugin); } header('Location: /admin/plugins'); exit; } /** * Toont en verwerkt de gebruikerslijst pagina. * * Toont alle gebruikers met zoek- en rolfilters. Op POST kan een gebruiker * verwijderd of van rol gewisseld worden. Verwacht een geldige CSRF-token. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleUsers($auth, $config, $twig, $user, $csrf): void { $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 === 'delete') { $deleteUser = $_POST['delete_username'] ?? ''; if ($deleteUser !== $user['username']) { $result = $auth->deleteUser($deleteUser); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } else { $message = 'Je kunt jezelf niet verwijderen.'; $messageType = 'danger'; } } elseif ($action === 'change_role') { $roleUser = $_POST['role_username'] ?? ''; $newRole = $_POST['new_role'] ?? ''; $result = $auth->changeRole($roleUser, $newRole); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } } } $route = 'users'; $users = $auth->getUsers(); $roles = AdminAuth::getRoles(); // Search and filter $search = trim($_GET['search'] ?? ''); $roleFilter = $_GET['role'] ?? ''; if ($search !== '') { $users = array_filter($users, function($u) use ($search) { return stripos($u['username'], $search) !== false || stripos($u['email'] ?? '', $search) !== false || stripos($u['author_name'] ?? '', $search) !== false; }); } if ($roleFilter !== '') { $users = array_filter($users, function($u) use ($roleFilter) { return $u['role'] === $roleFilter; }); } echo $twig->render('pages/users.twig', [ 'user' => $user, 'route' => $route, 'csrf_token' => $csrf, 'users' => $users, 'roles' => $roles, 'search' => $search, 'role_filter' => $roleFilter, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Toont en verwerkt de gebruiker-bewerkpagina. * * Ondersteunt profiel-bewerking (email, auteur-naam/email), rol-wissel en * wachtwoord-wijziging (alleen als de echte rol admin is) en verwijderen. * Een gebruiker kan zichzelf niet verwijderen. Verwacht een geldige CSRF-token. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleUsersEdit($auth, $config, $twig, $user, $csrf): void { $targetUsername = $_GET['user'] ?? ''; $targetUsername = preg_replace('/[^a-zA-Z0-9_\-]/', '', $targetUsername); if ($targetUsername === '') { header('Location: /admin/users'); exit; } $message = ''; $messageType = ''; // Password change is only allowed when the real role is admin // (i.e. not when testing via role-switch) $canChangePassword = $auth->getRealRole() === 'admin'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $action = $_POST['action'] ?? ''; if ($action === 'profile') { $profileEmail = trim($_POST['profile_email'] ?? ''); $profileAuthorName = trim($_POST['profile_author_name'] ?? ''); $profileAuthorEmail = trim($_POST['profile_author_email'] ?? ''); $result = $auth->updateUserProfile($targetUsername, $profileEmail, $profileAuthorName, $profileAuthorEmail); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } elseif ($action === 'change_role') { $newRole = $_POST['new_role'] ?? ''; $result = $auth->changeRole($targetUsername, $newRole); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } elseif ($action === 'change_password') { if (!$canChangePassword) { $message = 'Je mag geen wachtwoorden wijzigen in deze rol.'; $messageType = 'danger'; } else { $newPassword = $_POST['new_password'] ?? ''; $result = $auth->changePassword($targetUsername, $newPassword); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } } elseif ($action === 'delete') { if ($targetUsername !== $user['username']) { $result = $auth->deleteUser($targetUsername); header('Location: /admin/users?msg=' . urlencode($result['message'])); exit; } else { $message = 'Je kunt jezelf niet verwijderen.'; $messageType = 'danger'; } } } } $users = $auth->getUsers(); if (!isset($users[$targetUsername])) { header('Location: /admin/users'); exit; } $targetUser = $users[$targetUsername]; $roles = AdminAuth::getRoles(); $isSelf = ($targetUsername === $user['username']); echo $twig->render('pages/users-edit.twig', [ 'user' => $user, 'route' => 'users', 'csrf_token' => $csrf, 'target_user' => $targetUser, 'is_self' => $isSelf, 'can_change_password' => $canChangePassword, 'roles' => $roles, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Toont en verwerkt het aanmaken van een nieuwe gebruiker. * * Biedt een formulier om een nieuwe gebruiker aan te maken met gebruikersnaam, * wachtwoord, rol, email en auteur-gegevens. Op succes wordt doorverwezen * naar de gebruiker-bewerkpagina. Verwacht een geldige CSRF-token. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleUsersNew($auth, $config, $twig, $user, $csrf): void { $message = ''; $messageType = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { $message = 'Ongeldige CSRF token.'; $messageType = 'danger'; } else { $newUsername = trim($_POST['new_username'] ?? ''); $newPassword = $_POST['new_password'] ?? ''; $newRole = $_POST['new_role'] ?? 'content-manager'; $newEmail = trim($_POST['new_email'] ?? ''); $newAuthorName = trim($_POST['new_author_name'] ?? ''); $newAuthorEmail = trim($_POST['new_author_email'] ?? ''); $result = $auth->addUser($newUsername, $newPassword, $newRole, $newEmail, $newAuthorName, $newAuthorEmail); if ($result['success']) { header('Location: /admin/users-edit?user=' . urlencode($newUsername) . '&msg=' . urlencode($result['message'])); exit; } $message = $result['message']; $messageType = 'danger'; } } $roles = AdminAuth::getRoles(); echo $twig->render('pages/users-new.twig', [ 'user' => $user, 'route' => 'users', 'csrf_token' => $csrf, 'roles' => $roles, 'sidebar_color' => getSidebarColor($config), 'needs_editor' => false, 'message' => $message, 'message_type' => $messageType, ]); } /** * Toont een handleiding-pagina met Markdown-rendering en navigatie. * * Laadt een .md bestand uit guide//, rendert het met CommonMark (tabellen, * heading-permalinks) en toont het in de admin-layout met breadcrumbs en een * navigatie-zijbalk (via de Navigation plugin). Valt terug op Engels indien * de gevraagde taal niet bestaat. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ function handleGuide($auth, $config, $twig, $user, $csrf): void { $lang = $_GET['lang'] ?? 'nl'; $page = $_GET['page'] ?? ''; $rootDir = realpath(__DIR__ . '/..'); // Get guide file if ($page) { $guideFile = $rootDir . '/guide/' . $lang . '/' . $page . '.md'; } else { $guideFile = $rootDir . '/guide/' . $lang . '/index.md'; } // Fallback to English if (!file_exists($guideFile) && $lang !== 'en') { $guideFile = $rootDir . '/guide/en/' . ($page ? $page . '.md' : 'index.md'); } // Load content if (!file_exists($guideFile)) { $content = '

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', ]); } /** * Toont de update-pagina met CMS-versie en git-schrijfrechten. * * Leest de huidige versie uit version.php en controleert of de .git-map * schrijfbaar is voor eventuele updates. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array. * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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', ]); } /** * Toont de media-browser voor de content-directory. * * Toont een lijst van bestanden en mappen in de opgegeven subdir. Bevat * path-traversal bescherming via str_replace. * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir). * @param \Twig\Environment $twig Twig-omgeving voor rendering. * @param array $user Gebruikersgegevens van de ingelogde gebruiker. * @param string $csrf CSRF-token. * @return void */ 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 dat de lijst met mediabestanden recursief retourneert. * * Wordt gebruikt door de in-editor media-modal zodat de gebruiker een bestand * kan kiezen en in de content kan invoegen zonder de editor te verlaten. * * Scope: * - Standaard (geen ?plugin=): scant de content-directory, URLs zijn /content/... * - Met ?plugin=: scant plugins//assets/, URLs zijn /plugins//assets/... * - Met ?theme=: scant themes//assets/, URLs zijn /themes//assets/... * * Path-traversal bescherming: elk opgelost pad moet beginnen met de opgeloste * basismap (content-, plugin-assets- of theme-assets-map). * * @since 2.6.5 * * @param AdminAuth $auth Authenticatie-object. * @param array $config App-configuratie array (content_dir, plugins_dir). * @return void Output wordt als JSON naar de client gestuurd. */ function handleMediaList($auth, $config): void { header('Content-Type: application/json; charset=utf-8'); $mediaExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'mov', 'avi']; $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; // Determine the scan root and the URL prefix based on the scope $plugin = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['plugin'] ?? ''); $theme = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['theme'] ?? ''); if ($plugin !== '') { $pluginsDir = $config['plugins_dir']; $baseDir = rtrim($pluginsDir, '/') . '/' . $plugin . '/assets'; $urlPrefix = '/plugins/' . $plugin . '/assets/'; } elseif ($theme !== '') { $themesDir = __DIR__ . '/../themes'; $baseDir = rtrim($themesDir, '/') . '/' . $theme . '/assets'; $urlPrefix = '/themes/' . $theme . '/assets/'; } else { $baseDir = $config['content_dir']; // Serve content media through the /-media/ gateway; /content/ is // blocked (403) by public/index.php and lives outside the webroot. $urlPrefix = '/-media/'; } $realBase = realpath($baseDir); if (!$realBase || !is_dir($realBase)) { echo json_encode([]); return; } $items = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($realBase, RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $fileInfo) { if (!$fileInfo->isFile()) { continue; } $name = $fileInfo->getFilename(); // Skip hidden / dotfiles (incl. .gitkeep) if ($name !== '' && $name[0] === '.') { continue; } $ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, $mediaExts, true)) { continue; } $realPath = $fileInfo->getRealPath(); // Safety: must live inside the scan base if ($realPath === false || strpos($realPath, $realBase) !== 0) { continue; } $relPath = substr($realPath, strlen($realBase) + 1); $relPath = str_replace('\\', '/', $relPath); $items[] = [ 'name' => $name, 'path' => $relPath, 'url' => $urlPrefix . implode('/', array_map('rawurlencode', explode('/', $relPath))), 'extension' => $ext, 'is_image' => in_array($ext, $imageExts, true), 'size' => formatSize($fileInfo->getSize()), 'modified' => date('Y-m-d H:i', $fileInfo->getMTime()), ]; } // Sort: images first, then other media, alphabetically by name usort($items, function ($a, $b) { if ($a['is_image'] && !$b['is_image']) return -1; if (!$a['is_image'] && $b['is_image']) return 1; return strcasecmp($a['path'], $b['path']); }); echo json_encode($items, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } // ============================================================================ // HELPER FUNCTIONS (from original admin.php) // ============================================================================ /** * Schrijft een admin-logregel naar het logbestand en (indien beschikbaar) LogManager. * * @since 2.6.5 * * @param array $config App-configuratie array met de sleutel `log_file`. * @param string $level Logniveau (bijv. 'info', 'warning', 'error'). * @param string $message Logbericht. * @return void */ function adminLog(array $config, string $level, string $message): void { $logFile = $config['log_file']; $dir = dirname($logFile); if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $timestamp = date('Y-m-d H:i:s'); $ip = RequestLogger::getClientIp(); @file_put_contents($logFile, "[{$timestamp}] [{$level}] [{$ip}] {$message}\n", FILE_APPEND); if (class_exists('LogManager')) { LogManager::log(LogManager::EVENT_ADMIN, $level, $message, ['ip' => $ip]); } } /** * Telt recursief het aantal bestanden in een directory, optioneel gefilterd op extensies. * * @since 2.6.5 * * @param string $dir Te scannen directory (absoluut). * @param string[] $extensions Toegestane extensies (zonder punt). Leeg = alle bestanden. * @return int Aantal gevonden bestanden. 0 wanneer de dir niet bestaat. */ 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()) { if (empty($extensions) || in_array($file->getExtension(), $extensions)) { $count++; } } } return $count; } /** * Telt recursief het aantal mappen in een directory, exclusief dotfiles. * * @since 2.6.5 * * @param string $dir Te scannen directory (absoluut). * @return int Aantal gevonden mappen. 0 wanneer de dir niet bestaat. */ function countDirs(string $dir): int { $count = 0; if (!is_dir($dir)) return 0; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if ($file->isDir() && !str_starts_with($file->getFilename(), '.')) { $count++; } } return $count; } /** * Berekent recursief de totale bestandsgrootte (in bytes) van een directory. * * @since 2.6.5 * * @param string $dir Te scannen directory (absoluut). * @return int Totale grootte in bytes. 0 wanneer de dir niet bestaat. */ 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; } /** * Formatteert een byte-grootte naar een leesbare string (B/KB/MB/GB/TB). * * @since 2.6.5 * * @param int $bytes Aantal bytes. * @return string Geformatteerde grootte met eenheid (bijv. '1.5 MB'). */ function formatSize(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $i = 0; while ($bytes >= 1024 && $i < count($units) - 1) { $bytes /= 1024; $i++; } return round($bytes, 2) . ' ' . $units[$i]; } /** * Scant een content-directory en toont een platte lijst van bestanden en mappen. * * Toont per item de naam, pad, of het een map is, extensie, grootte en * wijzigingsdatum. Mappen worden eerst getoond, daarna bestanden alfabetisch. * * @since 2.6.5 * * @param string $fullPath Absoluut pad van de te scannen map. * @param string $subdir Relatieve subdir binnen content (voor pad-berekening). * @return array Item-lijst. Leeg wanneer de dir niet bestaat. */ function scanContentDir(string $fullPath, string $subdir): array { $items = []; if (!is_dir($fullPath)) return $items; $files = scandir($fullPath); foreach ($files as $file) { if ($file === '.' || $file === '..') { continue; } $path = $fullPath . '/' . $file; $isDir = is_dir($path); $items[] = [ 'name' => $file, 'path' => $subdir ? $subdir . '/' . $file : $file, 'is_dir' => $isDir, 'extension' => $isDir ? '' : strtolower(pathinfo($file, PATHINFO_EXTENSION)), 'size' => $isDir ? '-' : formatSize(filesize($path)), 'modified' => date('Y-m-d H:i', filemtime($path)), ]; } // Sort: directories first, then files, alphabetically usort($items, function($a, $b) { if ($a['is_dir'] && !$b['is_dir']) return -1; if (!$a['is_dir'] && $b['is_dir']) return 1; return strcasecmp($a['name'], $b['name']); }); return $items; } /** * Unified editor file-tree scanner (Fase 4 refactor). * * scanPluginFiles/scanThemeFiles/scanContentFiles hieronder zijn dunne wrappers * rond scanEditorFiles() voor backwards-compatibiliteit met bestaande call-sites. */ /** * Scant een directory en retourneert een geneste bestandsboom voor de editor-zijbalk. * * Uniforme helper gebruikt door de plugin-, theme- en content-editors (Fase 4 refactor). * * Scope: * - 'plugin': geen extra skips (dotfiles altijd overgeslagen) * - 'theme': slaat `assets/css_compiled/` over (runtime-artefact, read-only) * - 'content': geen extra skips (.bak/.git zijn dotfiles, al overgeslagen) * * Path-traversal bescherming: alleen directe scandir() van gevalideerde paden * wordt gebruikt; de echte basismap is het anker en elk opgelost kindpad wordt * gecontroleerd om daarbinnen te blijven. * * @since 2.6.5 * * @param string $dir Absoluut pad naar de te scannen directory. * @param string $scope Een van: 'plugin', 'theme', 'content'. Default 'plugin'. * @return array> Geneste lijst van nodes: [name, path, is_dir, extension, size, modified, children]. */ function scanEditorFiles(string $dir, string $scope = 'plugin'): array { $realBase = realpath($dir); if (!$realBase || !is_dir($realBase)) { return []; } // Determine which relative paths to skip per scope $skipPaths = []; if ($scope === 'theme') { $skipPaths['assets/css_compiled'] = true; } return scanEditorFilesNode($realBase, '', $realBase, $skipPaths, $scope); } /** * Recursieve helper voor scanEditorFiles(). Bouwt één niveau van de boom. * * @since 2.6.5 * * @param string $absDir Absoluut pad van de te scannen directory. * @param string $relPath Relatief pad van $absDir binnen de basis (leeg voor root). * @param string $realBase De echte basis-root (voor path-traversal guard). * @param array $skipPaths Map van relatieve paden om over te slaan (bijv. ['assets/css_compiled' => true]). * @param string $scope 'plugin' | 'theme' | 'content' — content toont . bestanden/mappen. * @return array> Nodes op dit niveau: [name, path, is_dir, extension, size, modified, children]. */ function scanEditorFilesNode(string $absDir, string $relPath, string $realBase, array $skipPaths = [], string $scope = 'plugin'): array { $nodes = []; if (!is_dir($absDir)) { return $nodes; } $entries = scandir($absDir); natcasesort($entries); foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } // Plugin/theme scope: skip dotfiles (.gitkeep, .mtime, .bak, .git) // Content scope: toon . bestanden/mappen (bijv. .map), maar sla .git/.gitkeep over if ($entry !== '' && $entry[0] === '.') { if ($scope !== 'content') { continue; } if ($entry === '.git' || $entry === '.gitkeep') { continue; } } $childRelPath = $relPath === '' ? $entry : ($relPath . '/' . $entry); // Skip scope-specific paths (e.g. theme css_compiled runtime artefact) if (isset($skipPaths[$childRelPath])) { continue; } $absChild = $absDir . '/' . $entry; $realChild = realpath($absChild); // Safety: every child must live inside the base dir if ($realChild === false || strpos($realChild, $realBase) !== 0) { continue; } $isDir = is_dir($realChild); $node = [ 'name' => $entry, 'path' => $childRelPath, 'is_dir' => $isDir, 'extension' => $isDir ? '' : strtolower(pathinfo($entry, PATHINFO_EXTENSION)), 'size' => $isDir ? '-' : formatSize(filesize($realChild)), 'modified' => date('Y-m-d H:i', filemtime($realChild)), 'children' => [], ]; if ($isDir) { $node['children'] = scanEditorFilesNode($realChild, $childRelPath, $realBase, $skipPaths, $scope); } $nodes[] = $node; } // Sort: directories first, then files, alphabetically (case-insensitive) usort($nodes, function ($a, $b) { if ($a['is_dir'] && !$b['is_dir']) return -1; if (!$a['is_dir'] && $b['is_dir']) return 1; return strcasecmp($a['name'], $b['name']); }); return $nodes; } /** * Backwards-compatibele wrappers (delegeren naar scanEditorFiles). * * Behouden zodat bestaande call-sites (handlePluginsEdit, handleThemeEdit, * handleContentFiles) ongewijzigd blijven gedurende/na de Fase 4 refactor. */ /** * Scant de bestandsboom van een plugin-directory. * * @since 2.6.5 * * @param string $pluginDir Absoluut pad naar de plugin-directory. * @return array> Geneste bestandsboom. */ function scanPluginFiles(string $pluginDir): array { return scanEditorFiles($pluginDir, 'plugin'); } /** * Scant de bestandsboom van een thema-directory. * * @since 2.6.5 * * @param string $themeDir Absoluut pad naar de thema-directory. * @return array> Geneste bestandsboom. */ function scanThemeFiles(string $themeDir): array { return scanEditorFiles($themeDir, 'theme'); } /** * Scant de bestandsboom van een content-directory. * * @since 2.6.5 * * @param string $contentDir Absoluut pad naar de content-directory. * @return array> Geneste bestandsboom. */ function scanContentFiles(string $contentDir): array { return scanEditorFiles($contentDir, 'content'); } /** * Verzamelt alle content-pagina's voor een default_page selector. * * Taal-geprefixeerde bestanden (nl./en.) worden van hun prefix ontdaan voor de * sleutel. Mappen worden meegenomen als ze content bevatten (vertegenwoordigd * door hun pad). * * @since 2.6.5 * * @param string $contentDir Absoluut pad naar de content-directory. * @return array Gesorteerde lijst van [pageKey => displayLabel]. */ function collectContentPages(string $contentDir): array { $pages = []; $realBase = realpath($contentDir); if (!$realBase || !is_dir($realBase)) { return $pages; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($realBase, RecursiveDirectoryIterator::SKIP_DOTS) ); $langRegex = '/^(nl|en|de|fr)\./'; foreach ($iterator as $fileInfo) { if (!$fileInfo->isFile()) { continue; } $ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, ['md', 'php', 'html'], true)) { continue; } $relative = substr($fileInfo->getRealPath(), strlen($realBase) + 1); $relative = str_replace('\\', '/', $relative); // Skip hidden / dash-prefixed segments (private assets etc.) $skip = false; foreach (explode('/', $relative) as $segment) { if ($segment !== '' && $segment[0] === '-') { $skip = true; break; } } if ($skip) { continue; } // Strip extension $key = preg_replace('/\.(md|php|html)$/i', '', $relative); // Strip language prefix from the filename component $dirPart = dirname($key); $dirPart = ($dirPart === '.' || $dirPart === '') ? '' : $dirPart . '/'; $filePart = basename($key); if (preg_match($langRegex, $filePart, $m)) { $filePart = substr($filePart, strlen($m[1]) + 1); } $key = $dirPart . $filePart; // folder/index -> folder if (str_ends_with($key, '/index')) { $key = substr($key, 0, -6); } if ($key === '') { $key = 'index'; } $label = ucfirst(str_replace(['-', '/'], [' ', ' / '], $key)); $pages[$key] = $label; } ksort($pages); return $pages; } /** * Werkt een frontmatter-sleutel bij in content of voegt deze toe. * * @since 2.6.5 * * @param string $content Content met optionele --- frontmatter. * @param string $key Frontmatter-sleutel om bij te werken. * @param string $value Nieuwe waarde voor de sleutel. * @return string Content met bijgewerkte frontmatter (of nieuwe frontmatter-block). */ function updateContentFrontmatter(string $content, string $key, string $value): string { if (preg_match('/^---\s*\n(.+?)\n---\s*\n(.*)$/s', $content, $m)) { $frontmatter = $m[1]; $body = $m[2]; $lines = explode("\n", $frontmatter); $found = false; foreach ($lines as $i => $line) { if (str_starts_with($line, $key . ':')) { $lines[$i] = $key . ': ' . $value; $found = true; break; } } if (!$found) { $lines[] = $key . ': ' . $value; } return "---\n" . implode("\n", $lines) . "\n---\n" . $body; } return "---\n" . $key . ': ' . $value . "\n---\n\n" . $content; } /** * Leest een enkele waarde uit de frontmatter van content. * * @since 2.6.5 * * @param string $content Content met optionele --- frontmatter. * @param string $key Frontmatter-sleutel om te lezen. * @return string Waarde van de sleutel, of lege string indien afwezig. */ function extractFrontmatterValue(string $content, string $key): string { if (preg_match('/^---\s*\n(.+?)\n---/s', $content, $m)) { $frontmatter = $m[1]; if (preg_match('/^' . preg_quote($key, '/') . ': (.+?)$/m', $frontmatter, $mm)) { return trim($mm[1]); } } return ''; } /** * Bepaalt de taal-prefix van een bestandsnaam (bijv. 'nl' voor 'nl.test.md'). * * @since 2.6.5 * * @param string $file Bestandsnaam of -pad. * @return string Taalcode ('nl', 'en', 'de', 'fr', 'es') of 'nl' als standaard. */ function extractLanguagePrefix(string $file): string { $parts = explode('/', $file); $filename = end($parts); if (preg_match('/^(nl|en|de|fr|es)\./', $filename, $m)) { return $m[1]; } return 'nl'; } /** * Maakt een timestamped backup van een bestand in een .bak submap. * * Kopieert het bestand naar /.bak/.. * * @since 2.6.5 * * @param string $filePath Absoluut pad naar het te backuppen bestand. * @return void */ function backupContentFile(string $filePath): void { $backupDir = dirname($filePath) . '/.bak'; if (!is_dir($backupDir)) { @mkdir($backupDir, 0755, true); } $backupFile = $backupDir . '/' . basename($filePath) . '.' . date('YmdHis'); @copy($filePath, $backupFile); }