From 74612aefbb9180c59a49a968cec0dc8bedbb15bf Mon Sep 17 00:00:00 2001 From: Edwin Noorlander Date: Thu, 27 Aug 2026 08:57:05 +0000 Subject: [PATCH] Security: verwijder hardcoded wachtwoord, voeg random-wachtwoord-generator toe bij eerste installatie --- README.en.md | 3 +- README.md | 3 +- TODO.md | 30 +- admin/src/AdminAuth.php | 441 +++- admin/storage/logs/login_attempts.json | 2 +- admin/theme/default/views/login.twig | 8 + .../default/views/pages/_editor-styles.twig | 35 +- .../theme/default/views/pages/_file-tree.twig | 121 +- .../default/views/pages/content-edit.twig | 11 +- .../default/views/pages/content-files.twig | 143 +- .../default/views/pages/plugins-edit.twig | 117 +- .../theme/default/views/pages/theme-edit.twig | 122 +- cli/generate-world-map.php | 54 +- cli/geoip-update.php | 36 + cli/reset-admin-password.php | 36 +- cms/core/class/Analytics.php | 68 +- cms/core/class/BotGuard.php | 53 +- cms/core/class/Cache.php | 140 ++ cms/core/class/CodePressCMS.php | 618 ++++-- cms/core/class/ContentBackup.php | 177 +- cms/core/class/GeoIP.php | 191 +- cms/core/class/LogManager.php | 236 ++- cms/core/class/Logger.php | 162 +- cms/core/class/RateLimiter.php | 70 + cms/core/class/RequestLogger.php | 121 +- cms/core/class/ThemeManager.php | 214 +- cms/core/config.php | 40 + cms/core/index.php | 80 +- cms/core/plugin/AdminPluginAPI.php | 133 +- cms/core/plugin/CMSAPI.php | 263 ++- cms/core/plugin/PluginAPIInterface.php | 84 +- cms/core/plugin/PluginManager.php | 279 ++- cms/router.php | 50 + guide/en/admin-beheerder/content-beheer.md | 40 +- guide/en/admin-beheerder/plugins.md | 43 +- guide/en/admin-beheerder/thema-beheer.md | 29 +- guide/nl/admin-beheerder/content-beheer.md | 40 +- guide/nl/admin-beheerder/plugins.md | 43 +- guide/nl/admin-beheerder/thema-beheer.md | 29 +- language/de/admin.php | 7 +- language/en/admin.php | 7 +- language/nl/admin.php | 7 +- plugins/Dashboard/Dashboard.php | 90 + plugins/GeoIPInfo/GeoIPInfo.php | 45 +- plugins/HTMLBlock/HTMLBlock.php | 55 + plugins/Logs/Logs.php | 52 +- plugins/Navigation/Navigation.php | 142 +- plugins/Statistics/Statistics.php | 56 +- public/admin.php | 1883 ++++++++++++++++- public/asset.php | 35 + public/index.php | 109 +- vendor/composer/InstalledVersions.php | 20 +- vendor/composer/LICENSE | 2 - vendor/composer/platform_check.php | 5 +- version.php | 15 +- 55 files changed, 6019 insertions(+), 876 deletions(-) diff --git a/README.en.md b/README.en.md index f23a400..23b9c4d 100644 --- a/README.en.md +++ b/README.en.md @@ -55,10 +55,9 @@ composer install ```bash cp config.json.example config.json -cp admin/config/admin.json.example admin/config/admin.json ``` -Edit `config.json` with your site title, language and plugins. Change the admin password in `admin/config/admin.json` (default `admin`/`admin`). +Edit `config.json` with your site title, language and plugins. On the first visit to `/admin`, `admin/config/admin.json` is created automatically with a random password that is shown on the login screen. Save this password safely and change it immediately after login. ### Step 3a — Apache 2.4+ diff --git a/README.md b/README.md index c97312b..c6974cd 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,9 @@ composer install ```bash cp config.json.example config.json -cp admin/config/admin.json.example admin/config/admin.json ``` -Pas `config.json` aan met je site titel, taal en plugins. Wijzig het admin wachtwoord in `admin/config/admin.json` (standaard `admin`/`admin`). +Pas `config.json` aan met je site titel, taal en plugins. Bij de eerste keer dat je `/admin` opent wordt `admin/config/admin.json` automatisch aangemaakt met een willekeurig wachtwoord dat op het inlogscherm wordt getoond. Sla dit wachtwoord veilig op en wijzig het direct na login. ### Stap 3a — Apache 2.4+ diff --git a/TODO.md b/TODO.md index 9b6c232..459e5db 100644 --- a/TODO.md +++ b/TODO.md @@ -1,17 +1,17 @@ # TODO -## bug -- [x] Admin /content - - [x] onderwater heet de is bij Admin de content, theme en plugin de pluginFileTree overal de zelfde naam. Dit is niet logies en consistent. Als de zelfde twig gebruikt word hou dan de naamgeving algemeen. - - [x] Bij Admin/content "Content bestanden" werkt de > bij het openen van de mappen niet. Wees consistenter en compacter. Hergebuik code en maak het universeler. Deze kan gelijk zijn aan de theme en plugin . - - [x] bij media invoeren zie ik nu card's voor de images of media. Dit moet anders. Gebruik weer een filetree - - [x] Bij het editen van de content zie ik Zichtbare plugin's. Hier staan nu ook system plugin's in. Maak hier een pulldown multiselect waar ik alleen de active content plugin's ziet - - [x] Ook zie ik hier Auteur naam en auteur email, maar deze zijn niet ingevult. Deze zijn bekend omdat ik ingelocht bent. - - [x] De auteur_name en auteur_email moeten hier weg - - [x] Ook wil ik dit zie bij elke aanpassing van een pagina --- created: xx-xx-xx xx:xx:xx edited: xx-xx-xx xx:xx:xx --- - - [x] De git init is hier niet handig. Haal dit weg. Dit wordt later een systeem plugin - - [x] Houw de code consistend en compact. How in de gaten dat de functionaliteit niet omzeep geholpen wordt. - - [x] GET http://development.codepress.noorlander.info/content/_images/D_D.jpg → 403 Forbidden — opgelost: /admin/media-list content-scope gebruikt nu /-media/ prefix i.p.v. /content/ + +## Voltooid ✅ + +- [x] diep link werken niet goed. Pagina titels worden wel herkend maar de gegenereerde link werken niet. — processContent() herschreef geldige // URLs naar /-media/ (404). Skip-regex uitgebreid met dynamische taal-prefixen via getAvailableLanguages(). +- [x] De tree van de admin/content admin/theme en admin/plugin + - [x] Mappen en bestanden moeten kunnen worden versleept kunnen worden om te verplaatsen. — Drag-and-drop via tree-interactions.js + uniform AJAX move endpoint (/admin/tree-move) voor alle drie de scopes. + - [x] Ik moet in de tree ook een bestand/map kunnen selecteren + - [x] om te kunnen editen — klik op bestand opent editor, klik op map opent map-detail paneel + - [x] verwijderen — prullenbak-knop per bestand in tree + verwijder-knop per map in map-detail paneel + - [x] op deze plaats een nieuwe map/bestand te kunnen maken — per-map actie-knoppen in tree (nieuw bestand, nieuwe map) + knoppen in map-detail paneel + - [x] verplaatsen — drag-and-drop + potlood-knop per bestand (verplaats-formulier) + - [x] Alle tree's moeten op de zelfde manier kunnen werken — _file-tree.twig is uniform: alle drie de trees hebben treeDirActions, treeDirSelectRoute, drag-drop, per-node actie-knoppen, map-detail paneel. Nieuwe routes: theme-dir-create-in/rename-in/delete-in, plugins-dir-create-in/rename-in/delete-in, tree-move. ## Nice to have - [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name) @@ -37,6 +37,10 @@ - [ ] AGENTS.md + config.json.example bijwerken - [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen +## v2.6.4 (2026-08-26) ✅ +- [x] Bug: diep link werken niet goed — processContent() herschreef geldige // URLs naar /-media/ (404); skip-regex dynamisch uitgebreid met getAvailableLanguages() +- [x] Bug: uniforme tree met drag-and-drop voor admin/content, admin/theme, admin/plugin — tree-interactions.js (drag-drop + per-node actie-knoppen), uniform AJAX move endpoint (/admin/tree-move), map-detail paneel voor alle drie de scopes, theme/plugin dir create/rename/delete routes, handleidingen bijgewerkt + ## Voltooid ✅ - [x] Admin code en niet gebruikte mappen/bestanden opschonen @@ -127,4 +131,4 @@ - [x] Pentest controles uitgevoerd (30/30) - [x] WCAG 2.1 AA accessibility tests (25/25) - [x] Verslag gemaakt (docs/release-notes/v2.6.0.md) -- [x] Versie verhoogd naar 2.6.0 \ No newline at end of file +- [x] Versie verhoogd naar 2.6.0 diff --git a/admin/src/AdminAuth.php b/admin/src/AdminAuth.php index 0f6cf1f..b172647 100644 --- a/admin/src/AdminAuth.php +++ b/admin/src/AdminAuth.php @@ -1,18 +1,60 @@ Map van rol-sleutel naar lijst met toegestane routes. */ public const ROLE_PERMISSIONS = [ 'admin' => ['*'], @@ -22,7 +64,10 @@ class AdminAuth ]; /** - * Human-readable role labels. + * Leesbare rol-labels voor weergave in de admin-interface. + * + * @since 2.6.4 + * @var array Map van rol-sleutel naar label. */ public const ROLE_LABELS = [ 'admin' => 'Admin', @@ -31,6 +76,16 @@ class AdminAuth 'site-admin' => 'Site Admin', ]; + /** + * Constructor: initialiseer authenticatie met de applicatie-configuratie. + * + * Laadt de admin-configuratie, bepaalt het pad naar het lockout-bestand en + * start de sessie met de juiste security-instellingen. + * + * @since 2.6.4 + * + * @param array $appConfig Applicatie-configuratie; vereist keys 'log_file' en 'admin_config'. + */ public function __construct(array $appConfig) { $this->config = $appConfig; @@ -39,6 +94,17 @@ class AdminAuth $this->startSession(); } + /** + * Laadt de admin-configuratie uit admin.json. + * + * Als het bestand nog niet bestaat wordt het aangemaakt vanuit + * admin.json.example, of indien die ook ontbreekt met een standaard admin- + * gebruiker. Geeft een lege structuur terug als de JSON ongeldig is. + * + * @since 2.6.4 + * + * @return array Admin-configuratie met keys 'users' en 'security'. + */ private function loadAdminConfig(): array { $path = $this->config['admin_config']; @@ -48,11 +114,19 @@ class AdminAuth if (file_exists($examplePath)) { @copy($examplePath, $path); } else { + // Genereer een cryptografisch veilig wachtwoord bij eerste + // installatie. Het wachtwoord wordt nooit in de broncode + // opgeslagen; de hash wordt weggeschreven naar admin.json + // en het plaintext wachtwoord éénmalig getoond op het + // inlogscherm via getGeneratedPassword(). + $plainPassword = $this->generateRandomPassword(); + $this->generatedPassword = $plainPassword; + $defaultAdminConfig = [ 'users' => [ [ 'username' => 'admin', - 'password_hash' => password_hash('admin', PASSWORD_BCRYPT), + 'password_hash' => password_hash($plainPassword, PASSWORD_BCRYPT), 'role' => 'admin', 'created' => date('Y-m-d'), ] @@ -74,6 +148,55 @@ class AdminAuth return is_array($data) ? $data : ['users' => [], 'security' => []]; } + /** + * Genereer een cryptografisch veilig willekeurig wachtwoord. + * + * Gebruik random_bytes() (CSPRNG) met een mix van alfanumerieke tekens + * en een vaste lengte, zodat het wachtwoord voldoende entropie heeft + * en veilig te typen is. + * + * @since 2.6.4 + * + * @return string Willekeurig wachtwoord van 16 alfanumerieke tekens. + */ + private function generateRandomPassword(int $length = 16): string + { + $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + $max = strlen($alphabet) - 1; + $password = ''; + for ($i = 0; $i < $length; $i++) { + $password .= $alphabet[random_int(0, $max)]; + } + return $password; + } + + /** + * Geef het bij eerste installatie gegenereerde wachtwoord terug. + * + * De login-pagina gebruikt dit om het wachtwoord eenmalig te tonen + * zodra admin.json is aangemaakt. Na de eerste succesvolle login + * wordt het veld geleegd. Geeft een lege string terug wanneer er + * geen nieuw wachtwoord is gegenereerd in deze request. + * + * @since 2.6.4 + * + * @return string Plaintext wachtwoord of lege string. + */ + public function getGeneratedPassword(): string + { + return $this->generatedPassword; + } + + /** + * Slaat de huidige admin-configuratie op naar admin.json. + * + * Schrijft de interne admin-configuratie weg als pretty-printed JSON met + * behoud van unicode-tekens. + * + * @since 2.6.4 + * + * @return void + */ public function saveAdminConfig(): void { file_put_contents( @@ -82,6 +205,17 @@ class AdminAuth ); } + /** + * Start de sessie met veilige cookie-parameters en handhaaft de session timeout. + * + * Stelt cookie-flags in (HttpOnly, SameSite=Strict, Secure bij HTTPS), start + * de sessie en beëindigt deze bij inactiviteit langer dan de geconfigureerde + * timeout. Vernieuwt de activiteit-timestamp bij geauthenticeerde gebruikers. + * + * @since 2.6.4 + * + * @return void + */ private function startSession(): void { if (session_status() === PHP_SESSION_NONE) { @@ -113,6 +247,20 @@ class AdminAuth } } + /** + * Verifieert inloggegevens en start een admin-sessie bij succes. + * + * Controleert eerst of het account niet is vergrendeld door brute-force + * protection. Bij een fout wachtwoord wordt een mislukte poging geregistreerd + * en gelogd. Bij succes worden mislukte pogingen gewist en de sessie + * gevuld met gebruiker, rol en CSRF-token. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @param string $password Wachtwoord (plaintext). + * @return array Resultaat met keys 'success' (bool) en 'message' (string). + */ public function login(string $username, string $password): array { // Check brute-force lockout @@ -145,6 +293,16 @@ class AdminAuth return ['success' => true, 'message' => 'Ingelogd.']; } + /** + * Logt de huidige gebruiker uit en vernietigt de sessie. + * + * Maakt de sessie-array leeg, verwijdert het sessie-cookie en vernietigt de + * sessie. De uitlogactie wordt gelogd. + * + * @since 2.6.4 + * + * @return void + */ public function logout(): void { $user = $_SESSION['admin_user'] ?? 'unknown'; @@ -160,11 +318,28 @@ class AdminAuth $this->log('info', "Uitgelogd: {$user}"); } + /** + * Controleert of de huidige bezoeker is ingelogd. + * + * @since 2.6.4 + * + * @return bool True indien een admin-gebruiker in de sessie staat. + */ public function isAuthenticated(): bool { return isset($_SESSION['admin_user']); } + /** + * Geeft de gegevens van de ingelogde gebruiker, verrijkt met profielvelden. + * + * Levert username en rol uit de sessie en vult deze aan met email, + * author_name en author_email uit admin.json indien aanwezig. + * + * @since 2.6.4 + * + * @return array|null Gebruikersdata als array, of null indien niet ingelogd. + */ public function getCurrentUser(): ?array { if (!$this->isAuthenticated()) { @@ -186,8 +361,14 @@ class AdminAuth } /** - * Get the role of the current user. - * Returns the override role if set (for testing), otherwise the real role. + * Geeft de huidige rol van de ingelogde gebruiker. + * + * Retourneert de override-rol indien ingesteld (voor test-doeleinden), anders + * de werkelijke rol. + * + * @since 2.6.4 + * + * @return string Rol-sleutel. */ public function getCurrentRole(): string { @@ -198,7 +379,11 @@ class AdminAuth } /** - * Get the real role of the current user (ignoring any override). + * Geeft de werkelijke rol van de ingelogde gebruiker, eventuele override negerend. + * + * @since 2.6.4 + * + * @return string Rol-sleutel. */ public function getRealRole(): string { @@ -206,7 +391,11 @@ class AdminAuth } /** - * Check if the current user has an active role override. + * Controleert of de ingelogde gebruiker een actieve rol-override heeft. + * + * @since 2.6.4 + * + * @return bool True indien een override-rol is ingesteld. */ public function hasRoleOverride(): bool { @@ -214,8 +403,16 @@ class AdminAuth } /** - * Temporarily switch to another role for testing purposes. - * Only admin users can do this; the override cannot grant more than admin. + * Wisselt tijdelijk naar een andere rol voor test-doeleinden. + * + * Alleen werkelijke admins kunnen wisselen; admins kunnen niet wisselen naar + * 'admin' (zinloos) en enkel naar lagere rollen. Slaat de override op in de + * sessie. De actie wordt gelogd. + * + * @since 2.6.4 + * + * @param string $role Doel-rol-sleutel. + * @return array Resultaat met keys 'success' (bool) en 'message' (string). */ public function switchRole(string $role): array { @@ -240,7 +437,13 @@ class AdminAuth } /** - * Reset the role override back to the real admin role. + * Reset de rol-override terug naar de werkelijke admin-rol. + * + * Verwijdert de override uit de sessie indien aanwezig en logt de actie. + * + * @since 2.6.4 + * + * @return array Resultaat met keys 'success' (bool) en 'message' (string). */ public function resetRole(): array { @@ -253,7 +456,15 @@ class AdminAuth } /** - * Check if the current user has permission to access a route. + * Controleert of de huidige gebruiker een bepaalde route mag openen. + * + * Gebruikt de permissies van de huidige rol; de wildcard '*' geeft toegang + * tot alles. Onbekende rollen krijgen enkel 'dashboard' en 'logout'. + * + * @since 2.6.4 + * + * @param string $route Route-sleutel die gecontroleerd moet worden. + * @return bool True indien de gebruiker de route mag benaderen. */ public function hasPermission(string $route): bool { @@ -268,7 +479,11 @@ class AdminAuth } /** - * Get available roles. + * Geeft alle beschikbare rollen met hun leesbare labels. + * + * @since 2.6.4 + * + * @return array Map van rol-sleutel naar label. */ public static function getRoles(): array { @@ -276,13 +491,28 @@ class AdminAuth } /** - * Get role label. + * Geeft het leesbare label voor een rol. + * + * @since 2.6.4 + * + * @param string $role Rol-sleutel. + * @return string Leesbaar label, of de rol-sleutel zelf indien onbekend. */ public static function getRoleLabel(string $role): string { return self::ROLE_LABELS[$role] ?? $role; } + /** + * Geeft het huidige CSRF-token, of genereert een nieuw token indien afwezig. + * + * Slaat een cryptografisch willekeurig token op in de sessie en retourneert + * dit voor gebruik in admin-formulieren. + * + * @since 2.6.4 + * + * @return string 64-tekens hexadecimaal CSRF-token. + */ public function getCsrfToken(): string { if (!isset($_SESSION['admin_csrf_token'])) { @@ -291,11 +521,31 @@ class AdminAuth return $_SESSION['admin_csrf_token']; } + /** + * Verifieert een meegegeven CSRF-token tegen het token in de sessie. + * + * Gebruikt hash_equals om timing-attacks te voorkomen. + * + * @since 2.6.4 + * + * @param string $token Token uit het formulier of verzoek. + * @return bool True indien het token overeenkomt met het sessie-token. + */ public function verifyCsrf(string $token): bool { return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token); } + /** + * Regeneert het CSRF-token in de sessie. + * + * Maakt een nieuw cryptografisch willekeurig token aan, nuttig na + * privilege-wijzigingen of na het verwerken van een formulier. + * + * @since 2.6.4 + * + * @return void + */ public function regenerateCsrfToken(): void { $_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32)); @@ -303,6 +553,16 @@ class AdminAuth // --- User Management --- + /** + * Geeft alle gebruikers met hun profiel- en rolinformatie. + * + * Levert een array geïndexeerd op gebruikersnaam, aangevuld met het + * leesbare rol-label en eventuele profielvelden. + * + * @since 2.6.4 + * + * @return array Map van gebruikersnaam naar gebruikersdata. + */ public function getUsers(): array { $users = []; @@ -321,6 +581,23 @@ class AdminAuth return $users; } + /** + * Voegt een nieuwe gebruiker toe aan de admin-configuratie. + * + * Controleert op dubbele gebruikersnamen, minimum wachtwoordlengte (8) en + * geldigheid van de rol. Het wachtwoord wordt opgeslagen als bcrypt-hash. + * Wijzigingen worden direct weggeschreven en gelogd. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @param string $password Wachtwoord (plaintext, minimaal 8 tekens). + * @param string $role Rol-sleutel. Default 'admin'. + * @param string $email E-mailadres van de gebruiker. Default ''. + * @param string $authorName Naam voor auteur-metadata. Default ''. + * @param string $authorEmail E-mail voor auteur-metadata. Default ''. + * @return array Resultaat met keys 'success' (bool) en 'message' (string). + */ public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): array { if ($this->findUser($username)) { @@ -348,7 +625,18 @@ class AdminAuth } /** - * Update the profile (email, author_name, author_email) of a user. + * Werkt de profielvelden (email, author_name, author_email) van een gebruiker bij. + * + * Zoekt de gebruiker op naam en overschrijft de profielvelden, slaat de + * configuratie op en logt de wijziging. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @param string $email E-mailadres. Default ''. + * @param string $authorName Naam voor auteur-metadata. Default ''. + * @param string $authorEmail E-mail voor auteur-metadata. Default ''. + * @return array Resultaat met keys 'success' (bool) en 'message' (string). */ public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array { @@ -366,8 +654,17 @@ class AdminAuth } /** - * Change the role of an existing user. - * A user can never change their own role (security guard). + * Wijzigt de rol van een bestaande gebruiker. + * + * Een gebruiker kan nooit zijn eigen rol wijzigen (security guard). De rol + * moet een bekende sleutel uit ROLE_PERMISSIONS zijn. Wijzigingen worden + * opgeslagen en gelogd. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @param string $role Nieuwe rol-sleutel. + * @return array Resultaat met keys 'success' (bool) en 'message' (string). */ public function changeRole(string $username, string $role): array { @@ -388,6 +685,17 @@ class AdminAuth return ['success' => false, 'message' => 'Gebruiker niet gevonden.']; } + /** + * Verwijdert een gebruiker uit de admin-configuratie. + * + * Een gebruiker kan zichzelf niet verwijderen. De gebruikerslijst wordt + * gefilterd, opgeslagen en de actie gelogd. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @return array Resultaat met keys 'success' (bool) en 'message' (string). + */ public function deleteUser(string $username): array { if ($username === ($_SESSION['admin_user'] ?? '')) { @@ -403,6 +711,18 @@ class AdminAuth return ['success' => true, 'message' => 'Gebruiker verwijderd.']; } + /** + * Zet een nieuw wachtwoord voor een gebruiker. + * + * Controleert de minimum wachtwoordlengte (8), hasht het wachtwoord met de + * standaard PHP-hash en slaat de wijziging op. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens). + * @return array Resultaat met keys 'success' (bool) en 'message' (string). + */ public function changePassword(string $username, string $newPassword): array { if (strlen($newPassword) < 8) { @@ -419,6 +739,19 @@ class AdminAuth return ['success' => false, 'message' => 'Gebruiker niet gevonden.']; } + /** + * Wijzigt het eigen wachtwoord na verificatie van het huidige wachtwoord. + * + * Verifieert eerst het huidige wachtwoord tegen de opgeslagen hash, controleert + * de lengte van het nieuwe wachtwoord (minimaal 8) en slaat de nieuwe hash op. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @param string $currentPassword Huidig wachtwoord (plaintext). + * @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens). + * @return array Resultaat met keys 'success' (bool) en 'message' (string). + */ public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array { $user = $this->findUser($username); @@ -444,6 +777,14 @@ class AdminAuth // --- Private helpers --- + /** + * Zoekt een gebruiker op gebruikersnaam in de admin-configuratie. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @return array|null Gebruikersdata als array, of null indien niet gevonden. + */ private function findUser(string $username): ?array { foreach ($this->adminConfig['users'] ?? [] as $user) { @@ -454,6 +795,18 @@ class AdminAuth return null; } + /** + * Controleert of een gebruiker momenteel is vergrendeld door brute-force protection. + * + * Vergelijkt het aantal mislukte pogingen en de verstreken tijd sinds de + * laatste poging met de geconfigureerde limieten. Verloopt de lockout dan + * worden de pogingen gewist. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @return array Resultaat met keys 'locked' (bool) en optioneel 'remaining' (int, seconden). + */ private function checkLockout(string $username): array { $attempts = $this->getFailedAttempts(); @@ -477,6 +830,17 @@ class AdminAuth return ['locked' => false]; } + /** + * Registreert een mislukte inlogpoging voor een gebruiker. + * + * Verhoogt de teller en actualiseert de timestamp van de laatste poging, + * waarna de gegevens worden weggeschreven naar het lockout-bestand. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @return void + */ private function recordFailedAttempt(string $username): void { $attempts = $this->getFailedAttempts(); @@ -488,6 +852,16 @@ class AdminAuth file_put_contents($this->lockFile, json_encode($attempts)); } + /** + * Wist alle mislukte inlogpogingen voor een gebruiker. + * + * Verwijdert de gebruiker uit het lockout-bestand en slaat dit op. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @return void + */ private function clearFailedAttempts(string $username): void { $attempts = $this->getFailedAttempts(); @@ -496,13 +870,28 @@ class AdminAuth } /** - * Clear all failed login attempts for a user (public, used by CLI reset). + * Wist de lockout voor een gebruiker (publiek, gebruikt door CLI reset). + * + * Wrapper rond clearFailedAttempts die ook van buiten de class kan worden + * aangeroepen, bijvoorbeeld door een CLI-tool voor het resetten van vergrendelingen. + * + * @since 2.6.4 + * + * @param string $username Gebruikersnaam. + * @return void */ public function clearLockout(string $username): void { $this->clearFailedAttempts($username); } + /** + * Leest alle mislukte inlogpogingen uit het lockout-bestand. + * + * @since 2.6.4 + * + * @return array Map van gebruikersnaam naar poging-data. + */ private function getFailedAttempts(): array { if (!file_exists($this->lockFile)) { @@ -512,6 +901,18 @@ class AdminAuth return is_array($data) ? $data : []; } + /** + * Schrijft een bericht naar het logbestand met timestamp, level en IP-adres. + * + * Maakt de logdirectory aan indien nodig. Het IP-adres wordt via RequestLogger + * bepaald indien beschikbaar, anders via REMOTE_ADDR met een fallback. + * + * @since 2.6.4 + * + * @param string $level Log-niveau (bijv. 'info', 'warning'). + * @param string $message Bericht dat gelogd moet worden. + * @return void + */ private function log(string $level, string $message): void { $logFile = $this->config['log_file']; diff --git a/admin/storage/logs/login_attempts.json b/admin/storage/logs/login_attempts.json index 551b521..e2b36e3 100644 --- a/admin/storage/logs/login_attempts.json +++ b/admin/storage/logs/login_attempts.json @@ -1 +1 @@ -{"admi":{"count":1,"last_attempt":1771257322}} \ No newline at end of file +{"admi":{"count":1,"last_attempt":1771257322},"":{"count":4,"last_attempt":1787141227}} \ No newline at end of file diff --git a/admin/theme/default/views/login.twig b/admin/theme/default/views/login.twig index cfc8f17..1ee40b1 100644 --- a/admin/theme/default/views/login.twig +++ b/admin/theme/default/views/login.twig @@ -27,6 +27,14 @@ {% endif %} + {% if generated_password %} + + {% endif %}
diff --git a/admin/theme/default/views/pages/_editor-styles.twig b/admin/theme/default/views/pages/_editor-styles.twig index eba7b33..a9e46c3 100644 --- a/admin/theme/default/views/pages/_editor-styles.twig +++ b/admin/theme/default/views/pages/_editor-styles.twig @@ -13,24 +13,37 @@ .editor-tree .tree-toggle.is-selected { background-color: rgba(13,110,253,.12); font-weight: 600; } .editor-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; } .editor-tree .tree-chevron { flex: 0 0 auto; } +.editor-tree .tree-chevron-btn { background: transparent; border: 0; color: inherit; padding: .2rem .4rem; line-height: 1.4; flex: 0 0 auto; cursor: pointer; } +.editor-tree .tree-chevron-btn:hover { color: var(--bs-primary, #0d6efd); } +.editor-tree .tree-name { flex: 1 1 auto; min-width: 0; } +.editor-tree .tree-name.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; } +.editor-tree .tree-root-link { display: flex; align-items: center; gap: .3rem; padding: .25rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; font-weight: 600; } +.editor-tree .tree-root-link:hover { background-color: rgba(13,110,253,.08); } +.editor-tree .tree-root-link.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); } .editor-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; } .editor-tree .badge-ext { font-size: .6rem; margin-left: auto; } .editor-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.editor-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; } -.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; } -.editor-tree .tree-delete-btn:hover { color: #b02a37; } -.editor-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; } -.editor-tree .tree-move-btn:hover { color: #0d6efd; } -.editor-tree li > .tree-link { padding-right: 3.5rem; } +.editor-tree .tree-move-btn, +.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; background: transparent; border: 0; } +.editor-tree .tree-move-btn:hover, +.editor-tree .tree-delete-btn:hover { color: #0d6efd; } +.editor-tree .tree-delete-btn:hover { color: #dc3545; } +.editor-tree .tree-file-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; opacity: 0; transition: opacity .15s; } +.editor-tree li:hover > .tree-file-actions { opacity: 1; } +.editor-tree li > .tree-link { padding-right: 4rem; } +/* Drag-and-drop states */ +.editor-tree li.tree-dragging { opacity: .5; } +.editor-tree .tree-drag-over { background-color: rgba(13,110,253,.18) !important; outline: 2px dashed var(--bs-primary, #0d6efd); outline-offset: -2px; border-radius: .25rem; } +/* Toast */ +.tree-toast { border-radius: .375rem; } .plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; } -/* Directory action buttons (new file/dir, rename, delete) — content editor only */ -.tree-dir-row { position: relative; } -.tree-dir-row .tree-toggle { padding-right: 7rem; } +/* Directory action buttons (new file/dir, rename) — content editor only */ +.tree-dir-row { position: relative; display: flex; align-items: center; } +.tree-dir-row > .tree-name { padding-right: 3rem; } +.tree-dir-row > .tree-toggle { padding-right: 5rem; } .tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; } .tree-dir-actions .btn-link { color: #6c757d; } .tree-dir-actions .btn-link:hover { color: #0d6efd; } -.tree-dir-actions .tree-delete-btn { color: #dc3545; } -.tree-dir-actions .tree-delete-btn:hover { color: #b02a37; } @media (max-width: 768px) { .editor-layout { flex-direction: column; } .plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; } diff --git a/admin/theme/default/views/pages/_file-tree.twig b/admin/theme/default/views/pages/_file-tree.twig index a9c26c7..1a6fb70 100644 --- a/admin/theme/default/views/pages/_file-tree.twig +++ b/admin/theme/default/views/pages/_file-tree.twig @@ -6,25 +6,33 @@ - files: array of nodes [{name, path, is_dir, extension, size, modified, children}] - relFile: currently selected relative file path (for active highlight + auto-expand) - editableExts: array of editable extensions (shown as .EXT badge) - - csrf_token: CSRF token for delete forms - treeIdPrefix: prefix for collapse element ids (e.g. 'content-tree', 'plugin-tree', 'theme-tree') - treeRouteBase: admin route base used to build file links, e.g. '/admin/content' - treeRouteParams: extra query string for file links, e.g. 'plugin=MyPlugin&' or 'theme=default&' or '' (content) - - treeDeleteRoute: admin route for file delete, e.g. '/admin/content-file-delete' - - treeDeleteName: optional hidden field name for delete form (e.g. 'plugin' or 'theme'); emits no field when empty - - treeDeleteValue: optional hidden field value for treeDeleteName (e.g. the plugin/theme name) - treeMoveRoute: admin route for file move, e.g. '/admin/plugins-file-move' - treeMoveParams: extra query string for move link, e.g. 'plugin=X&' or '' - - treeDirActions: boolean; show per-folder new-file/new-dir/rename/delete buttons (content editor only) + - treeDirActions: boolean; show per-folder new-file/new-dir/rename buttons (default true) + - treeFileMove: boolean; show per-file move/rename pencil button (default true) - treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json']) + - treeDirSelectRoute: if set, folder names are links to ?dir= (map-detail pane) + - treeDirRenameRoute: if set, show a rename pencil per folder + - treeDeleteBase: POST URL for file deletion (scope-specific) + - treeScope: 'content' | 'theme' | 'plugin' (for tree-interactions.js) + - treeMoveUrl: AJAX endpoint for drag-drop move (e.g. '/admin/tree-move') + - treeScopeParam: extra query param string (e.g. 'plugin=MyPlugin&' or 'theme=default&' or '') Icon maps are chosen automatically from the node extension. #} {% macro tree(nodes, opts) %} {% import _self as macros %}
    {% for n in nodes %} - {% set isActive = n.path == opts.relFile %} - {% set containsActive = n.is_dir and opts.relFile starts with (n.path ~ '/') %} + {# Een bestand is alleen actief wanneer er geen map geselecteerd is + (een map-selectie opent het map-detail paneel, geen bestand). #} + {% set isActive = n.path == opts.relFile and opts.selectedDir|default('') == '' %} + {# Markeer de oudermap "bevat actieve selectie" voor auto-uitklappen: + bij een geselecteerd bestand OF een geselecteerde (sub)map. #} + {% set activePath = opts.selectedDir|default('') != '' ? opts.selectedDir : opts.relFile %} + {% set containsActive = n.is_dir and activePath != '' and activePath starts with (n.path ~ '/') %} {% if n.is_dir %} {% set icon = 'bi-folder-fill text-warning' %} {% elseif n.extension == 'md' %} @@ -48,37 +56,42 @@ {% else %} {% set icon = 'bi-file text-muted' %} {% endif %} -
  • +
  • {% if n.is_dir %} {% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %} -
    - + {# Mapnaam: selecteer de map. Voor content gaat dit naar ?dir= (map-detail paneel rechts); + voor plugins/themes is het een niet-navigerende toggle (legacy gedrag). #} + {% if opts.treeDirSelectRoute %} + + + {{ n.name }} + + {% else %} + + {% endif %} {% if opts.treeDirActions %} - - - + {% endif %} + {% if opts.treeDirRenameRoute %} + - - - - - + {% endif %} {% endif %}
    @@ -99,22 +112,20 @@ {{ n.extension|upper }} {% endif %} - {% if n.path not in (opts.treeHideActionsFor ?? []) %} - - - -
    - - {% if opts.treeDeleteName %} - + + {% if n.path not in (opts.treeHideActionsFor ?? []) %} + {% set showFileMove = opts.treeFileMove is defined ? opts.treeFileMove : true %} + {% if showFileMove %} + + + {% endif %} - - - - {% endif %} + {% endif %} +
    {% endif %}
  • {% endfor %} @@ -129,29 +140,48 @@
    {{ treeHeader|default('Bestanden') }}
    -
    +
    + {% if treeDirSelectRoute is defined and treeDirSelectRoute is not empty %} + {# Klikbare content-root: selecteert de root map (?dir= leeg). + data-dir="" maakt de root ook een drag-drop doel (lege string = base dir). #} + + + {{ treeRootLabel|default('content') }} + + {% endif %} {% if files is empty %}
    {{ treeEmptyText|default('Geen bestanden gevonden.') }}
    {% else %} {{ tree_macros.tree(files, { 'relFile': relFile, + 'selectedDir': selectedDir|default(''), 'editableExts': editableExts, - 'csrfToken': csrf_token, 'treeIdPrefix': treeIdPrefix, 'treeRouteBase': treeRouteBase, 'treeRouteParams': treeRouteParams, - 'treeDeleteRoute': treeDeleteRoute, - 'treeDeleteName': treeDeleteName|default(''), - 'treeDeleteValue': treeDeleteValue|default(''), 'treeMoveRoute': treeMoveRoute, 'treeMoveParams': treeMoveParams, - 'treeDirActions': treeDirActions|default(false), + 'treeDirActions': treeDirActions is defined ? treeDirActions : true, + 'treeDirSelectRoute': treeDirSelectRoute|default(''), + 'treeDirRenameRoute': treeDirRenameRoute|default(''), + 'treeFileMove': treeFileMove is defined ? treeFileMove : true, 'treeHideActionsFor': treeHideActionsFor|default([]), + 'newFileModalId': newFileModalId|default('#newFileModal'), + 'newDirModalId': newDirModalId|default(''), }) }} {% endif %}
+ \ No newline at end of file diff --git a/admin/theme/default/views/pages/content-edit.twig b/admin/theme/default/views/pages/content-edit.twig index 8656ee4..73ceb28 100644 --- a/admin/theme/default/views/pages/content-edit.twig +++ b/admin/theme/default/views/pages/content-edit.twig @@ -60,8 +60,9 @@ {% endif %} +
- {% if isEditable %} @@ -70,8 +71,14 @@ {% endif %} {{ ta.back|default('Terug') }} +
+ + + +
- diff --git a/admin/theme/default/views/pages/content-files.twig b/admin/theme/default/views/pages/content-files.twig index 46f68c0..8e090ab 100644 --- a/admin/theme/default/views/pages/content-files.twig +++ b/admin/theme/default/views/pages/content-files.twig @@ -60,23 +60,105 @@ 'files': files, 'relFile': relFile, 'editableExts': editableExts, - 'csrf_token': csrf_token, 'treeIdPrefix': 'content-tree', 'treeHeader': ta.content_files|default('Content bestanden'), 'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'), 'treeRouteBase': '/admin/content', 'treeRouteParams': '', - 'treeDeleteRoute': '/admin/content-file-delete', - 'treeDeleteName': '', - 'treeDeleteValue': '', 'treeMoveRoute': '/admin/content-file-move', 'treeMoveParams': '', 'treeDirActions': true, + 'treeDirSelectRoute': '/admin/content', + 'treeDirRenameRoute': '', + 'treeFileMove': false, 'treeHideActionsFor': [], + 'selectedDir': selectedDir|default(''), + 'treeRootLabel': 'content', + 'treeScope': 'content', + 'treeMoveUrl': '/admin/tree-move', + 'treeDeleteBase': '/admin/content-file-delete', + 'treeScopeParam': '', + 'newFileModalId': '#newFileModal', + 'newDirModalId': '#newDirModal', } %} - {# Editor main panel #} + {# Editor / map-detail main panel #}
+ {% if showMapPaneel %} + {# ── Map detail paneel ( rechts) ── tonen wanneer een map is geselecteerd #} + + +
+
+ + {{ selectedDirIsRoot ? 'content' : selectedDirName }} + {% if not selectedDirIsRoot %} + content/{{ selectedDir }} + {% endif %} +
+
+ {# Mapnaam bewerkbaar + Opslaan = hernoem (geen apart potlood-icoon in de boom meer) #} +
+ + + + +
+ + +
+
{{ ta.folder_rename_help|default('Bewerk de mapnaam en klik opslaan om te hernoemen.') }}
+
+ + {# Map acties: nieuw bestand / nieuwe map / upload — opereren op de geselecteerde map #} +
+ + + + {% if not selectedDirIsRoot %} +
+ + + +
+ {% endif %} +
+ + {# Korte inhoud-samenvatting van de geselecteerde map #} +
+ + {{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }}, + {{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }} +
+
+
+ + {% else %} {% if relFile %}
+
- {{ ta.preview|default('Preview') }} +
+ + + +
- {% else %}
@@ -165,11 +254,12 @@ {% if relFile %} {{ ta.content_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }} {% else %} - {{ ta.content_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }} + {{ ta.content_select_file|default('Selecteer een bestand of map uit de zijbalk.') }} {% endif %}
{% endif %} + {% endif %}{# end showMapPaneel #} @@ -180,7 +270,7 @@
- +