Vervolg op v2.6.6: vier plekken lazen nog ruwe enabled_plugins zonder essential-forcering. handleDashboard(), handleContentFiles/Edit() en AdminPluginAPI::getEnabledPlugins() forceren nu essential plugins consistent met PluginManager. Dode functie countEnabledPlugins() verwijderd. AGENTS.md aangevuld met verplichte docblock-sectie.
6375 lines
243 KiB
PHP
6375 lines
243 KiB
PHP
<?php
|
|
|
|
/**
|
|
* CodePress Admin Console - Entry Point
|
|
* Access via: //admin/login|dashboard|content|config|plugins|users|logout
|
|
*/
|
|
|
|
// Security headers
|
|
header('X-Content-Type-Options: nosniff');
|
|
header('X-Frame-Options: SAMEORIGIN');
|
|
header('X-XSS-Protection: 1; mode=block');
|
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
header_remove('X-Powered-By');
|
|
|
|
// Load Composer autoloader for CommonMark and Twig
|
|
$autoloader = __DIR__ . '/../vendor/autoload.php';
|
|
if (file_exists($autoloader)) {
|
|
require_once $autoloader;
|
|
}
|
|
|
|
// Load admin components
|
|
$appConfig = require __DIR__ . '/../admin/config/app.php';
|
|
require_once __DIR__ . '/../admin/src/AdminAuth.php';
|
|
require_once __DIR__ . '/../cms/core/class/RequestLogger.php';
|
|
require_once __DIR__ . '/../cms/core/class/BotGuard.php';
|
|
require_once __DIR__ . '/../cms/core/class/Cache.php';
|
|
require_once __DIR__ . '/../cms/core/class/GeoIP.php';
|
|
require_once __DIR__ . '/../cms/core/class/Analytics.php';
|
|
require_once __DIR__ . '/../cms/core/class/LogManager.php';
|
|
require_once __DIR__ . '/../cms/core/class/ContentBackup.php';
|
|
require_once __DIR__ . '/../cms/core/plugin/PluginAPIInterface.php';
|
|
require_once __DIR__ . '/../cms/core/plugin/CMSAPI.php';
|
|
require_once __DIR__ . '/../cms/core/plugin/AdminPluginAPI.php';
|
|
require_once __DIR__ . '/../cms/core/plugin/PluginManager.php';
|
|
|
|
// Initialize dynamic logging from site config
|
|
$siteConfigForLogging = file_exists($appConfig['config_json'])
|
|
? (json_decode(file_get_contents($appConfig['config_json']), true) ?? [])
|
|
: [];
|
|
LogManager::init($siteConfigForLogging['logging'] ?? []);
|
|
|
|
$auth = new AdminAuth($appConfig);
|
|
|
|
// Initialize Twig (admin theme: admin/theme/default/views)
|
|
$adminTheme = 'default';
|
|
$twigLoader = new \Twig\Loader\FilesystemLoader(__DIR__ . '/../admin/theme/' . $adminTheme . '/views');
|
|
$twig = new \Twig\Environment($twigLoader, [
|
|
'cache' => __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<string,string> 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<int,string> 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 <Plugin>.php 'type' => '...' (fallback als plugin.json geen type bevat)
|
|
*
|
|
* @since 2.6.5
|
|
*
|
|
* @param string $pluginsDir Absoluut pad naar de plugins-directory.
|
|
* @return array<int,array{name:string,title:string,type:string}> 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 . "<?php\n";
|
|
} else {
|
|
$stub = $frontmatter . "<h1>Nieuwe pagina</h1>\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 . "<?php\n";
|
|
} elseif ($ext === 'html') {
|
|
$content = $frontmatter . "<h1>Nieuwe pagina</h1>\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',
|
|
"<!DOCTYPE html>\n<html lang=\"{{ lang|default('nl') }}\">\n<head>\n"
|
|
. " <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
|
|
. " <title>{% block title %}{{ page_title|default(site_title) }}{% endblock %}</title>\n"
|
|
. " <link rel=\"stylesheet\" href=\"{{ theme_css_url|default('') }}\">\n"
|
|
. "</head>\n<body>\n{% include 'partials/header.twig' %}\n"
|
|
. "<main class=\"container my-4\">\n{% block content %}{% endblock %}\n</main>\n"
|
|
. "{% include 'partials/footer.twig' %}\n</body>\n</html>\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',
|
|
"<header>\n <h1>{{ page_title|default(site_title) }}</h1>\n</header>\n");
|
|
file_put_contents($themeDir . '/partials/footer.twig',
|
|
"<footer>\n <p>© {{ \"now\"|date('Y') }} {{ site_title|default('') }}</p>\n</footer>\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 = "<?php\n";
|
|
} elseif ($ext === 'json') {
|
|
$stub = "{\n}\n";
|
|
} elseif ($ext === 'twig') {
|
|
$stub = "{% extends 'base.twig' %}\n{% block content %}\n\n{% endblock %}\n";
|
|
} elseif ($ext === 'scss' || $ext === 'css') {
|
|
$stub = "/* " . basename($newName) . " */\n";
|
|
}
|
|
$written = @file_put_contents($newReal, $stub);
|
|
if ($written !== false) {
|
|
adminLog($config, 'info', $user['username'] . ' creëerde thema bestand ' . $theme . '/' . $newName);
|
|
$relFile = $newName;
|
|
$realPath = $newReal;
|
|
$message = 'Bestand aangemaakt.';
|
|
$messageType = 'success';
|
|
} else {
|
|
$err = error_get_last();
|
|
$reason = $err['message'] ?? 'onbekend';
|
|
adminLog($config, 'error', $user['username'] . ' kon thema bestand niet aanmaken ' . $theme . '/' . $newName . ': ' . $reason);
|
|
$message = 'Bestand aanmaken mislukt: ' . $reason;
|
|
$messageType = 'danger';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} elseif (($_POST['action'] ?? '') === 'new_dir') {
|
|
$newDir = trim($_POST['dirname'] ?? '');
|
|
$inDir = str_replace(['../', '..\\', './'], '', $_POST['in_dir'] ?? '');
|
|
$inDir = ltrim($inDir, '/');
|
|
foreach (explode('/', $inDir) as $seg) {
|
|
if ($seg === '..' || $seg === '.') { $inDir = ''; break; }
|
|
}
|
|
$newDir = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newDir);
|
|
if ($newDir === '' || $newDir === '.' || $newDir === '..') {
|
|
$message = 'Ongeldige mapnaam.';
|
|
$messageType = 'danger';
|
|
} else {
|
|
$newFullPath = $realThemeDir . '/' . ($inDir ? $inDir . '/' : '') . $newDir;
|
|
$parentReal = realpath($realThemeDir . '/' . $inDir);
|
|
if ($parentReal === false || strpos($parentReal, $realThemeDir) !== 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 thema map ' . $theme . '/' . ($inDir ? $inDir . '/' : '') . $newDir);
|
|
$message = 'Map aangemaakt.';
|
|
$messageType = 'success';
|
|
$selectedDir = $inDir ? $inDir . '/' . $newDir : $newDir;
|
|
} else {
|
|
$message = 'Map aanmaken mislukt.';
|
|
$messageType = 'danger';
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$content = $_POST['content'] ?? '';
|
|
if ($realPath && is_file($realPath)) {
|
|
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
|
if (in_array($ext, $editableExts, true)) {
|
|
file_put_contents($realPath, $content);
|
|
adminLog($config, 'info', $user['username'] . ' bewerkte thema ' . $theme . '/' . $relFile);
|
|
$message = 'Bestand opgeslagen.';
|
|
$messageType = 'success';
|
|
} else {
|
|
$message = 'Dit bestandstype kan niet bewerkt worden.';
|
|
$messageType = 'danger';
|
|
}
|
|
} else {
|
|
$message = 'Bestand niet gevonden.';
|
|
$messageType = 'danger';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Gather the theme file tree
|
|
$files = scanThemeFiles($themeDir);
|
|
|
|
// Load content of the selected file (if editable)
|
|
$isEditable = false;
|
|
$fileContent = '';
|
|
$fileExt = '';
|
|
if ($realPath && is_file($realPath)) {
|
|
$fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
|
if (in_array($fileExt, $editableExts, true)) {
|
|
$isEditable = true;
|
|
$fileContent = file_get_contents($realPath);
|
|
}
|
|
}
|
|
|
|
$route = 'theme-edit';
|
|
$showMapPaneel = $selectedDir !== '';
|
|
|
|
echo $twig->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', "<?php\n// Plugin: " . ucfirst($pluginName) . "\n\necho 'Hello from " . $pluginName . "';\n");
|
|
|
|
adminLog($config, 'info', $user['username'] . ' creëerde plugin ' . $pluginName);
|
|
header('Location: /admin/plugins-edit?plugin=' . urlencode($pluginName));
|
|
exit;
|
|
} else {
|
|
$message = 'Plugin bestaat al.';
|
|
$messageType = 'danger';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$route = 'plugins-new';
|
|
|
|
echo $twig->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 <Plugin>.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 = "<?php\n";
|
|
} elseif ($ext === 'json') {
|
|
$stub = "{\n}\n";
|
|
}
|
|
// file_put_contents can fail due to permissions; surface the reason
|
|
$written = @file_put_contents($newReal, $stub);
|
|
if ($written !== false) {
|
|
adminLog($config, 'info', $user['username'] . ' creëerde plugin bestand ' . $plugin . '/' . $newName);
|
|
// Switch to the new file
|
|
$relFile = $newName;
|
|
$realPath = $newReal;
|
|
$message = 'Bestand aangemaakt.';
|
|
$messageType = 'success';
|
|
} else {
|
|
$err = error_get_last();
|
|
$reason = $err['message'] ?? 'onbekend';
|
|
adminLog($config, 'error', $user['username'] . ' kon plugin bestand niet aanmaken ' . $plugin . '/' . $newName . ': ' . $reason);
|
|
$message = 'Bestand aanmaken mislukt: ' . $reason;
|
|
$messageType = 'danger';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} elseif (($_POST['action'] ?? '') === 'new_dir') {
|
|
$newDir = trim($_POST['dirname'] ?? '');
|
|
$inDir = str_replace(['../', '..\\', './'], '', $_POST['in_dir'] ?? '');
|
|
$inDir = ltrim($inDir, '/');
|
|
foreach (explode('/', $inDir) as $seg) {
|
|
if ($seg === '..' || $seg === '.') { $inDir = ''; break; }
|
|
}
|
|
$newDir = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newDir);
|
|
if ($newDir === '' || $newDir === '.' || $newDir === '..') {
|
|
$message = 'Ongeldige mapnaam.';
|
|
$messageType = 'danger';
|
|
} else {
|
|
$newFullPath = $realPluginDir . '/' . ($inDir ? $inDir . '/' : '') . $newDir;
|
|
$parentReal = realpath($realPluginDir . '/' . $inDir);
|
|
if ($parentReal === false || strpos($parentReal, $realPluginDir) !== 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 plugin map ' . $plugin . '/' . ($inDir ? $inDir . '/' : '') . $newDir);
|
|
$message = 'Map aangemaakt.';
|
|
$messageType = 'success';
|
|
$selectedDir = $inDir ? $inDir . '/' . $newDir : $newDir;
|
|
} else {
|
|
$message = 'Map aanmaken mislukt.';
|
|
$messageType = 'danger';
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Save the currently selected file
|
|
$content = $_POST['content'] ?? '';
|
|
if ($realPath && is_file($realPath)) {
|
|
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
|
if (in_array($ext, $editableExts, true)) {
|
|
file_put_contents($realPath, $content);
|
|
adminLog($config, 'info', $user['username'] . ' bewerkte plugin ' . $plugin . '/' . $relFile);
|
|
$message = 'Bestand opgeslagen.';
|
|
$messageType = 'success';
|
|
} else {
|
|
$message = 'Dit bestandstype kan niet bewerkt worden.';
|
|
$messageType = 'danger';
|
|
}
|
|
} else {
|
|
$message = 'Bestand niet gevonden.';
|
|
$messageType = 'danger';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Gather the plugin file tree
|
|
$files = scanPluginFiles($pluginDir);
|
|
|
|
// Load content of the selected file (if editable)
|
|
$isEditable = false;
|
|
$fileContent = '';
|
|
$fileExt = '';
|
|
if ($realPath && is_file($realPath)) {
|
|
$fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
|
if (in_array($fileExt, $editableExts, true)) {
|
|
$isEditable = true;
|
|
$fileContent = file_get_contents($realPath);
|
|
}
|
|
}
|
|
|
|
$route = 'plugins-edit';
|
|
$showMapPaneel = $selectedDir !== '';
|
|
|
|
echo $twig->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/<plugin>/assets/<subdir>/<filename>. 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/<lang>/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/<lang>/, 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 = '<p>Handleiding niet gevonden.</p>';
|
|
} 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=<naam>: scant plugins/<naam>/assets/, URLs zijn /plugins/<naam>/assets/...
|
|
* - Met ?theme=<naam>: scant themes/<naam>/assets/, URLs zijn /themes/<naam>/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<int,array{name:string,path:string,is_dir:bool,extension:string,size:string,modified:string}> 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<int,array<string,mixed>> 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<string,bool> $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<int,array<string,mixed>> 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<int,array<string,mixed>> 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<int,array<string,mixed>> 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<int,array<string,mixed>> 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<string,string> 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 <dirname>/.bak/<basename>.<timestamp>.
|
|
*
|
|
* @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);
|
|
}
|
|
|