diff --git a/AGENTS.md b/AGENTS.md
index bf7a27d..6673f05 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,22 +1,97 @@
# Agent Instructions for CodePress CMS
+## AI Model
+- **Huidig model**: `claude-opus-4-6` (OpenCode / `opencode/claude-opus-4-6`)
+- Sessie gestart: 16 feb 2026
+
## Build & Run
- **Run Server**: `php -S localhost:8080 -t public`
-- **Lint PHP**: `find . -name "*.php" -exec php -l {} \;`
-- **Dependencies**: No Composer/NPM required. Native PHP 8.4+ implementation.
+- **Lint PHP**: `find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \;`
+- **Dependencies**: Composer vereist voor CommonMark. Geen NPM.
+- **Admin Console**: Toegankelijk op `/admin.php` (standaard login: `admin` / `admin`)
+
+## Project Structuur
+```
+codepress/
+├── engine/
+│ ├── core/
+│ │ ├── class/
+│ │ │ ├── CodePressCMS.php # Hoofd CMS class
+│ │ │ ├── Logger.php # Logging systeem
+│ │ │ └── SimpleTemplate.php # Mustache-style template engine
+│ │ ├── plugin/
+│ │ │ ├── PluginManager.php # Plugin loader
+│ │ │ └── CMSAPI.php # API voor plugins
+│ │ ├── config.php # Config loader (leest config.json)
+│ │ └── index.php # Bootstrap (autoloader, requires)
+│ ├── lang/ # Taalbestanden (nl.php, en.php)
+│ └── templates/ # Mustache templates
+│ ├── layout.mustache # Hoofd layout (bevat inline CSS)
+│ ├── assets/
+│ │ ├── header.mustache
+│ │ ├── navigation.mustache
+│ │ └── footer.mustache
+│ ├── markdown_content.mustache
+│ ├── php_content.mustache
+│ └── html_content.mustache
+├── admin-console/ # Admin paneel
+│ ├── config/
+│ │ ├── app.php # Admin app configuratie
+│ │ └── admin.json # Gebruikers & security (file-based)
+│ ├── src/
+│ │ └── AdminAuth.php # Authenticatie (sessies, bcrypt, CSRF, lockout)
+│ ├── templates/
+│ │ ├── login.php # Login pagina
+│ │ ├── layout.php # Admin layout met sidebar
+│ │ └── pages/
+│ │ ├── dashboard.php
+│ │ ├── content.php
+│ │ ├── content-edit.php
+│ │ ├── content-new.php
+│ │ ├── config.php
+│ │ ├── plugins.php
+│ │ └── users.php
+│ └── storage/logs/ # Admin logs
+├── plugins/ # CMS plugins
+│ ├── HTMLBlock/
+│ └── MQTTTracker/
+├── public/ # Web root
+│ ├── assets/css/js/
+│ ├── index.php # Website entry point
+│ └── admin.php # Admin entry point + router
+├── content/ # Content bestanden
+├── guide/ # Handleidingen (nl/en)
+├── config.json # Site configuratie
+├── TODO.md # Openstaande verbeteringen
+└── AGENTS.md # Dit bestand
+```
## Code Style & Conventions
- **PHP Standards**: Follow PSR-12. Use 4 spaces for indentation.
- **Naming**: Classes `PascalCase` (e.g., `CodePressCMS`), methods `camelCase` (e.g., `renderMenu`), variables `camelCase`, config keys `snake_case`.
- **Architecture**:
- - Core logic resides in `index.php`.
- - Configuration in `config.php`.
- - Public entry point is `public/index.php`.
-- **Content**: Stored in `public/content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static).
-- **Templating**: Simple string replacement `{{placeholder}}` in `templates/layout.html`.
+ - Core CMS logic in `engine/core/class/CodePressCMS.php`
+ - Bootstrap/requires in `engine/core/index.php`
+ - Configuration loaded from `config.json` via `engine/core/config.php`
+ - Public website entry point: `public/index.php`
+ - Admin entry point + routing: `public/admin.php`
+ - Admin authenticatie: `admin-console/src/AdminAuth.php`
+- **Content**: Stored in `content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static).
+- **Templating**: Mustache-style `{{placeholder}}` in `templates/layout.mustache` via `SimpleTemplate.php`.
- **Navigation**: Auto-generated from directory structure. Folders require an index file to be clickable in breadcrumbs.
-- **Security**: Always use `htmlspecialchars()` for outputting user/content data.
-- **Git**: `main` is the clean CMS core. `e.noorlander` contains personal content. Do not mix them.
+- **Security**:
+ - Always use `htmlspecialchars()` for outputting user/content data
+ - Use `realpath()` + prefix-check for path traversal prevention
+ - Admin forms require CSRF tokens via `AdminAuth::verifyCsrf()`
+ - Passwords stored as bcrypt hashes in `admin.json`
+- **Git**: `main` is the clean CMS core. `development` is de actieve development branch. `e.noorlander` bevat persoonlijke content. Niet mixen.
+
+## Admin Console
+- **File-based**: Geen database. Gebruikers opgeslagen in `admin-console/config/admin.json`
+- **Routing**: Via `?route=` parameter in `public/admin.php`
+- **Routes**: `login`, `logout`, `dashboard`, `content`, `content-edit`, `content-new`, `content-delete`, `config`, `plugins`, `users`
+- **Auth**: Session-based. `AdminAuth` class handelt login, logout, CSRF, brute-force lockout af
+- **Templates**: Pure PHP templates in `admin-console/templates/pages/`. Layout in `layout.php`
## Important: Title vs File/Directory Name Logic
- **CRITICAL**: When user asks for "title" corrections, they usually mean **FILE/DIRECTORY NAME WITHOUT LANGUAGE PREFIX AND EXTENSIONS**, not the HTML title from content!
@@ -26,4 +101,10 @@
- `en.php-testen` → display as "Php Testen" (not "ICT")
- **Method**: Use `formatDisplayName()` to process file/directory names correctly
- **Priority**: Directory names take precedence over file names when both exist
-- **Language prefixes**: Always remove `nl.` or `en.` prefixes from display names
\ No newline at end of file
+- **Language prefixes**: Dynamisch verwijderd op basis van beschikbare talen via `getAvailableLanguages()`
+
+## Bekende aandachtspunten
+- LSP errors over "Undefined function" in PHP files zijn vals-positief (standaard PHP functies worden niet herkend door de LSP). Negeer deze.
+- Zie `TODO.md` voor alle openstaande verbeteringen en nieuwe features.
+- `vendor/` map bevat Composer dependencies (CommonMark, Mustache). Niet handmatig wijzigen.
+- `admin-console/config/admin.json` bevat wachtwoord-hashes. Niet committen met echte productie-wachtwoorden.
diff --git a/README.en.md b/README.en.md
index 10f2f7b..7e71ba6 100644
--- a/README.en.md
+++ b/README.en.md
@@ -17,6 +17,7 @@ A lightweight, file-based content management system built with PHP.
- ⚙️ **JSON Configuration** - Easy configuration via JSON
- 🎨 **Bootstrap 5** - Modern UI framework
- 🔒 **Security** - Secure content management (100/100 security score)
+- 🛡️ **Admin Console** - Built-in admin panel for content, config, plugins and user management
## 🚀 Quick Start
@@ -47,6 +48,13 @@ codepress/
│ ├── markdown_content.mustache
│ ├── php_content.mustache
│ └── html_content.mustache
+├── admin-console/ # Admin panel
+│ ├── config/
+│ │ ├── app.php # Admin configuration
+│ │ └── admin.json # Users & security
+│ ├── src/
+│ │ └── AdminAuth.php # Authentication service
+│ └── templates/ # Admin templates
├── public/ # Web root
│ ├── assets/
│ │ ├── css/
@@ -56,7 +64,8 @@ codepress/
│ │ ├── nl.homepage.md # Dutch homepage
│ │ ├── en.homepage.md # English homepage
│ │ └── [lang].[page].md # Multi-language pages
-│ └── index.php # Entry point
+│ ├── index.php # Website entry point
+│ └── admin.php # Admin entry point
├── config.json # Configuration
├── version.php # Version tracking
└── README.md # This file
@@ -315,6 +324,28 @@ See [pentest/PENTEST.md](pentest/PENTEST.md) for detailed security report.
See [function-test/test-report.md](function-test/test-report.md) for detailed test results.
+## 🛡️ Admin Console
+
+CodePress includes a built-in admin panel for managing your website.
+
+**Access:** `/admin.php` | **Default login:** `admin` / `admin`
+
+### Modules
+- **Dashboard** - Overview with statistics and quick actions
+- **Content** - Browse, create, edit and delete files
+- **Configuration** - Edit `config.json` with JSON validation
+- **Plugins** - Overview of installed plugins
+- **Users** - Add, remove users and change passwords
+
+### Security
+- Session-based authentication with bcrypt password hashing
+- CSRF protection on all forms
+- Brute-force protection (5 attempts, 15 min lockout)
+- Path traversal protection
+- Session timeout (30 min)
+
+> **Important:** Change the default password immediately after installation via Users.
+
## 📖 Documentation
- **[Guide (NL)](guide/nl.codepress.md)** - Dutch documentation
diff --git a/README.md b/README.md
index b0501a7..88a1b0f 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ Een lichtgewicht, file-based content management systeem gebouwd met PHP.
- ⚙️ **JSON Configuratie** - Eenvoudige configuratie via JSON
- 🎨 **Bootstrap 5** - Moderne UI framework
- 🔒 **Security** - Beveiligde content management
+- 🛡️ **Admin Console** - Ingebouwd admin paneel voor content, config, plugins en gebruikersbeheer
## 🚀 Quick Start
@@ -47,12 +48,20 @@ codepress/
│ │ ├── pagina1.md
│ │ └── pagina2.php
│ └── homepage.md
+├── admin-console/ # Admin paneel
+│ ├── config/
+│ │ ├── app.php # Admin configuratie
+│ │ └── admin.json # Gebruikers & security
+│ ├── src/
+│ │ └── AdminAuth.php # Authenticatie service
+│ └── templates/ # Admin templates
├── public/ # Web root
│ ├── assets/
│ │ ├── css/
│ │ ├── js/
│ │ └── favicon.svg
-│ └── index.php
+│ ├── index.php # Website entry point
+│ └── admin.php # Admin entry point
├── config.json # Configuratie
└── README.md
```
@@ -230,6 +239,28 @@ Hoofd CMS class die alle content management functionaliteit beheert.
- File metadata tracking
- Responsive template rendering
+## 🛡️ Admin Console
+
+CodePress bevat een ingebouwd admin paneel voor het beheren van je website.
+
+**Toegang:** `/admin.php` | **Standaard login:** `admin` / `admin`
+
+### Modules
+- **Dashboard** - Overzicht met statistieken en snelle acties
+- **Content** - Bestanden browsen, aanmaken, bewerken en verwijderen
+- **Configuratie** - `config.json` bewerken met JSON-validatie
+- **Plugins** - Overzicht van geinstalleerde plugins
+- **Gebruikers** - Gebruikers toevoegen, verwijderen en wachtwoorden wijzigen
+
+### Beveiliging
+- Session-based authenticatie met bcrypt password hashing
+- CSRF-bescherming op alle formulieren
+- Brute-force bescherming (5 pogingen, 15 min lockout)
+- Path traversal bescherming
+- Session timeout (30 min)
+
+> **Belangrijk:** Wijzig het standaard wachtwoord direct na installatie via Gebruikers.
+
## 📖 Documentatie
- **[Handleiding (NL)](guide/nl.codepress.md)** - Gedetailleerde handleiding
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..2c74ed3
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,82 @@
+# CodePress CMS - Verbeteringen TODO
+
+## Kritiek
+
+- [x] **Path traversal fix** - `str_replace('../')` in `getPage()` is te omzeilen. Gebruik `realpath()` met prefix-check (`CodePressCMS.php:313`)
+- [x] **JWT secret fallback** - Standaard `'your-secret-key-change-in-production'` maakt tokens forgeable (`admin-console/config/app.php:11`)
+- [x] **executePhpFile() onveilig** - Open `include` wrapper zonder pad-restrictie (`CMSAPI.php:164`)
+- [ ] **Plugin auto-loading** - Elke map in `plugins/` wordt blind geladen zonder allowlist of validatie (`PluginManager.php:40`)
+
+## Hoog
+
+- [x] **IP spoofing** - `X-Forwarded-For` header wordt blind vertrouwd in MQTTTracker (`MQTTTracker.php:211`)
+- [x] **Debug hardcoded** - `'debug' => true` hardcoded in admin config (`admin-console/config/app.php:6`)
+- [x] **Cookie security** - Cookies zonder `Secure`/`HttpOnly`/`SameSite` flags (`MQTTTracker.php:70`)
+- [ ] **autoLinkPageTitles()** - Regex kan geneste `` tags produceren (`CodePressCMS.php:587`)
+- [ ] **MQTT wachtwoord** - Credentials in plain text JSON (`MQTTTracker.php:37`)
+
+## Medium
+
+- [x] **Dead code** - Dubbele `is_dir()` check, tweede blok onbereikbaar (`CodePressCMS.php:328-333`)
+- [x] **htmlspecialchars() op bestandspad** - Corrumpeert bestandslookups in `getPage()` en `getContentType()` (`CodePressCMS.php:311, 1294`)
+- [x] **Ongebruikte methode** - `scanForPageNames()` wordt nergens aangeroepen (`CodePressCMS.php:658-679`)
+- [x] **Orphaned docblock** - Dubbel docblock zonder bijbehorende methode (`CodePressCMS.php:607-611`)
+- [x] **Extra ``** - Sluit een tag die nooit geopend is in `getDirectoryListing()` (`CodePressCMS.php:996`)
+- [x] **Dubbele require_once** - PluginManager/CMSAPI geladen in zowel index.php als constructor (`CodePressCMS.php:49-50`)
+- [x] **require_once autoload** - Autoloader opnieuw geladen in `parseMarkdown()` (`CodePressCMS.php:513`)
+- [x] **Breadcrumb titels ongeescaped** - `$title` direct in HTML zonder `htmlspecialchars()` (`CodePressCMS.php:1197`)
+- [x] **Zoekresultaat-URLs missen `&lang=`** - Taalparameter ontbreekt (`CodePressCMS.php:264`)
+- [x] **Operator precedence bug** - `!$x ?? true` evalueert als `(!$x) ?? true` (`MQTTTracker.php:131`)
+- [ ] **Taalwisselaar verliest pagina** - Wisselen van taal navigeert altijd naar homepage (`header.mustache:22`)
+- [ ] **ctime is geen creatietijd op Linux** - `stat()` ctime is inode-wijzigingstijd (`CodePressCMS.php:400`)
+- [ ] **getGuidePage() dupliceert markdown parsing** - Zelfde CommonMark setup als `parseMarkdown()` (`CodePressCMS.php:854`)
+- [ ] **HTMLBlock ontbrekende ``** - Niet-gesloten tags bij null-check (`HTMLBlock.php:68`)
+- [ ] **formatDisplayName() redundante logica** - Dubbele checks en overtollige str_replace (`CodePressCMS.php:688`)
+
+## Laag
+
+- [x] **Hardcoded 'Ga naar'** - Niet vertaalbaar in `autoLinkPageTitles()` (`CodePressCMS.php:587`)
+- [x] **HTML lang attribuut** - `` hardcoded i.p.v. dynamisch (`layout.mustache:2`)
+- [x] **console.log in productie** - Debug log in app.js (`app.js:54`)
+- [x] **Event listener leak** - N globale click listeners in forEach loop (`app.js:85`)
+- [x] **Sidebar toggle aria** - Ontbrekende `aria-label` en `aria-expanded` (`CodePressCMS.php:1171`)
+- [x] **Taalprefix hardcoded** - Alleen `nl|en` i.p.v. dynamisch uit config (`CodePressCMS.php:691, 190`)
+- [ ] **Geen type hints** - Ontbrekende type declarations op properties en methoden
+- [ ] **Public properties** - `$config`, `$currentLanguage`, `$searchResults` zouden private moeten zijn
+- [ ] **Inline CSS** - ~250 regels statische CSS in template i.p.v. extern bestand
+- [ ] **style.css is Bootstrap** - Bestandsnaam is misleidend, Bootstrap wordt mogelijk dubbel geladen
+- [ ] **Geen error handling op file_get_contents()** - Meerdere calls zonder return-check
+- [ ] **Logger slikt fouten** - `@file_put_contents()` met error suppression
+- [ ] **Logger tail() leest heel bestand** - Geheugenprobleem bij grote logbestanden
+- [ ] **Externe links missen rel="noreferrer"**
+- [ ] **Zoekformulier mist aria-label**
+- [ ] **mobile.css override Bootstrap utilities** met `!important`
+
+---
+
+## Admin Console - Nieuwe features
+
+### Hoog
+
+- [ ] **Markdown editor** - WYSIWYG/split-view Markdown editor integreren in content-edit (bijv. EasyMDE, SimpleMDE, of Toast UI Editor). Live preview, toolbar met opmaakknoppen, drag & drop afbeeldingen
+- [ ] **Plugin activeren/deactiveren** - Toggle knop per plugin in admin Plugins pagina. Schrijft `enabled: true/false` naar plugin `config.json`. PluginManager moet `enabled` status respecteren bij het laden
+- [ ] **Plugin API** - Uitgebreide API voor plugins zodat ze kunnen inhaken op CMS events (hooks/filters). Denk aan: `onPageLoad`, `onBeforeRender`, `onAfterRender`, `onSearch`, `onMenuBuild`. Plugins moeten sidebar content, head tags, en footer scripts kunnen injecteren
+
+### Medium
+
+- [ ] **Plugin configuratie editor** - Per-plugin config.json bewerken vanuit admin panel
+- [ ] **Bestand uploaden** - Afbeeldingen en bestanden uploaden via admin Content pagina
+- [ ] **Map aanmaken/verwijderen** - Directory management in admin Content pagina
+- [ ] **Admin activity log** - Logboek van alle admin acties (wie deed wat wanneer) met viewer in dashboard
+- [ ] **Wachtwoord wijzigen eigen account** - Apart formulier voor ingelogde gebruiker om eigen wachtwoord te wijzigen (met huidig wachtwoord verificatie)
+- [ ] **Admin thema** - Admin sidebar kleur overnemen van site thema config (`header_color`)
+
+### Laag
+
+- [ ] **Content preview** - Live preview van Markdown/HTML content naast de editor
+- [ ] **Content versioning** - Simpele file-based backup bij elke save (bijv. `.bak` bestanden)
+- [ ] **Zoeken in admin** - Zoekfunctie binnen de admin content browser
+- [ ] **Drag & drop** - Bestanden herordenen/verplaatsen via drag & drop
+- [ ] **Keyboard shortcuts** - Ctrl+S om op te slaan in editor, Ctrl+N voor nieuw bestand
+- [ ] **Dark mode** - Admin panel dark mode toggle
+- [ ] **Responsive admin** - Admin sidebar inklapbaar op mobiel (nu is het gestacked)
diff --git a/admin-console/config/admin.json b/admin-console/config/admin.json
new file mode 100644
index 0000000..0526dfc
--- /dev/null
+++ b/admin-console/config/admin.json
@@ -0,0 +1,15 @@
+{
+ "users": [
+ {
+ "username": "admin",
+ "password_hash": "$2y$12$nUpoaCNZZFL8kOTHNC85q.dTy0hWRmPoF3dAn4GcvSXERMioYr5b6",
+ "role": "admin",
+ "created": "2025-01-01"
+ }
+ ],
+ "security": {
+ "session_timeout": 1800,
+ "max_login_attempts": 5,
+ "lockout_duration": 900
+ }
+}
diff --git a/admin-console/config/app.php b/admin-console/config/app.php
new file mode 100644
index 0000000..5bebf66
--- /dev/null
+++ b/admin-console/config/app.php
@@ -0,0 +1,17 @@
+ 'CodePress Admin',
+ 'version' => '1.0.0',
+ 'debug' => $_ENV['APP_DEBUG'] ?? false,
+ 'timezone' => 'Europe/Amsterdam',
+
+ // Paths
+ 'admin_root' => __DIR__ . '/../',
+ 'codepress_root' => __DIR__ . '/../../',
+ 'content_dir' => __DIR__ . '/../../content/',
+ 'config_json' => __DIR__ . '/../../config.json',
+ 'plugins_dir' => __DIR__ . '/../../plugins/',
+ 'admin_config' => __DIR__ . '/admin.json',
+ 'log_file' => __DIR__ . '/../storage/logs/admin.log',
+];
diff --git a/admin-console/src/AdminAuth.php b/admin-console/src/AdminAuth.php
new file mode 100644
index 0000000..f9e0b9d
--- /dev/null
+++ b/admin-console/src/AdminAuth.php
@@ -0,0 +1,279 @@
+config = $appConfig;
+ $this->adminConfig = $this->loadAdminConfig();
+ $this->lockFile = dirname($appConfig['log_file']) . '/login_attempts.json';
+ $this->startSession();
+ }
+
+ private function loadAdminConfig(): array
+ {
+ $path = $this->config['admin_config'];
+ if (!file_exists($path)) {
+ return ['users' => [], 'security' => []];
+ }
+ $data = json_decode(file_get_contents($path), true);
+ return is_array($data) ? $data : ['users' => [], 'security' => []];
+ }
+
+ public function saveAdminConfig(): void
+ {
+ file_put_contents(
+ $this->config['admin_config'],
+ json_encode($this->adminConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
+ );
+ }
+
+ private function startSession(): void
+ {
+ if (session_status() === PHP_SESSION_NONE) {
+ $timeout = $this->adminConfig['security']['session_timeout'] ?? 1800;
+ session_set_cookie_params([
+ 'lifetime' => $timeout,
+ 'path' => '/',
+ 'secure' => isset($_SERVER['HTTPS']),
+ 'httponly' => true,
+ 'samesite' => 'Lax'
+ ]);
+ session_start();
+ }
+
+ // Check session timeout
+ if (isset($_SESSION['admin_last_activity'])) {
+ $timeout = $this->adminConfig['security']['session_timeout'] ?? 1800;
+ if (time() - $_SESSION['admin_last_activity'] > $timeout) {
+ $this->logout();
+ return;
+ }
+ }
+
+ if ($this->isAuthenticated()) {
+ $_SESSION['admin_last_activity'] = time();
+ }
+ }
+
+ public function login(string $username, string $password): array
+ {
+ // Check brute-force lockout
+ $lockout = $this->checkLockout($username);
+ if ($lockout['locked']) {
+ return [
+ 'success' => false,
+ 'message' => 'Account tijdelijk vergrendeld. Probeer over ' . $lockout['remaining'] . ' seconden opnieuw.'
+ ];
+ }
+
+ // Find user
+ $user = $this->findUser($username);
+ if (!$user || !password_verify($password, $user['password_hash'])) {
+ $this->recordFailedAttempt($username);
+ $this->log('warning', "Mislukte inlogpoging: {$username}");
+ return ['success' => false, 'message' => 'Onjuiste gebruikersnaam of wachtwoord.'];
+ }
+
+ // Success - clear failed attempts
+ $this->clearFailedAttempts($username);
+
+ // Set session
+ $_SESSION['admin_user'] = $username;
+ $_SESSION['admin_role'] = $user['role'] ?? 'admin';
+ $_SESSION['admin_last_activity'] = time();
+ $_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
+
+ $this->log('info', "Ingelogd: {$username}");
+ return ['success' => true, 'message' => 'Ingelogd.'];
+ }
+
+ public function logout(): void
+ {
+ $user = $_SESSION['admin_user'] ?? 'unknown';
+ $_SESSION = [];
+ if (ini_get('session.use_cookies')) {
+ $params = session_get_cookie_params();
+ setcookie(session_name(), '', time() - 42000,
+ $params['path'], $params['domain'],
+ $params['secure'], $params['httponly']
+ );
+ }
+ session_destroy();
+ $this->log('info', "Uitgelogd: {$user}");
+ }
+
+ public function isAuthenticated(): bool
+ {
+ return isset($_SESSION['admin_user']);
+ }
+
+ public function getCurrentUser(): ?array
+ {
+ if (!$this->isAuthenticated()) {
+ return null;
+ }
+ return [
+ 'username' => $_SESSION['admin_user'],
+ 'role' => $_SESSION['admin_role'] ?? 'admin'
+ ];
+ }
+
+ public function getCsrfToken(): string
+ {
+ if (!isset($_SESSION['admin_csrf_token'])) {
+ $_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
+ }
+ return $_SESSION['admin_csrf_token'];
+ }
+
+ public function verifyCsrf(string $token): bool
+ {
+ return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
+ }
+
+ // --- User Management ---
+
+ public function getUsers(): array
+ {
+ return array_map(function ($u) {
+ return [
+ 'username' => $u['username'],
+ 'role' => $u['role'] ?? 'admin',
+ 'created' => $u['created'] ?? ''
+ ];
+ }, $this->adminConfig['users'] ?? []);
+ }
+
+ public function addUser(string $username, string $password, string $role = 'admin'): array
+ {
+ if ($this->findUser($username)) {
+ return ['success' => false, 'message' => 'Gebruiker bestaat al.'];
+ }
+ if (strlen($password) < 8) {
+ return ['success' => false, 'message' => 'Wachtwoord moet minimaal 8 tekens zijn.'];
+ }
+
+ $this->adminConfig['users'][] = [
+ 'username' => $username,
+ 'password_hash' => password_hash($password, PASSWORD_DEFAULT),
+ 'role' => $role,
+ 'created' => date('Y-m-d')
+ ];
+ $this->saveAdminConfig();
+ $this->log('info', "Gebruiker aangemaakt: {$username}");
+ return ['success' => true, 'message' => 'Gebruiker aangemaakt.'];
+ }
+
+ public function deleteUser(string $username): array
+ {
+ if ($username === ($_SESSION['admin_user'] ?? '')) {
+ return ['success' => false, 'message' => 'Je kunt jezelf niet verwijderen.'];
+ }
+
+ $this->adminConfig['users'] = array_values(array_filter(
+ $this->adminConfig['users'],
+ fn($u) => $u['username'] !== $username
+ ));
+ $this->saveAdminConfig();
+ $this->log('info', "Gebruiker verwijderd: {$username}");
+ return ['success' => true, 'message' => 'Gebruiker verwijderd.'];
+ }
+
+ public function changePassword(string $username, string $newPassword): array
+ {
+ if (strlen($newPassword) < 8) {
+ return ['success' => false, 'message' => 'Wachtwoord moet minimaal 8 tekens zijn.'];
+ }
+ foreach ($this->adminConfig['users'] as &$user) {
+ if ($user['username'] === $username) {
+ $user['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
+ $this->saveAdminConfig();
+ $this->log('info', "Wachtwoord gewijzigd: {$username}");
+ return ['success' => true, 'message' => 'Wachtwoord gewijzigd.'];
+ }
+ }
+ return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
+ }
+
+ // --- Private helpers ---
+
+ private function findUser(string $username): ?array
+ {
+ foreach ($this->adminConfig['users'] ?? [] as $user) {
+ if ($user['username'] === $username) {
+ return $user;
+ }
+ }
+ return null;
+ }
+
+ private function checkLockout(string $username): array
+ {
+ $attempts = $this->getFailedAttempts();
+ $maxAttempts = $this->adminConfig['security']['max_login_attempts'] ?? 5;
+ $lockoutDuration = $this->adminConfig['security']['lockout_duration'] ?? 900;
+
+ if (!isset($attempts[$username])) {
+ return ['locked' => false];
+ }
+
+ $record = $attempts[$username];
+ if ($record['count'] >= $maxAttempts) {
+ $elapsed = time() - $record['last_attempt'];
+ if ($elapsed < $lockoutDuration) {
+ return ['locked' => true, 'remaining' => $lockoutDuration - $elapsed];
+ }
+ // Lockout expired
+ $this->clearFailedAttempts($username);
+ }
+
+ return ['locked' => false];
+ }
+
+ private function recordFailedAttempt(string $username): void
+ {
+ $attempts = $this->getFailedAttempts();
+ if (!isset($attempts[$username])) {
+ $attempts[$username] = ['count' => 0, 'last_attempt' => 0];
+ }
+ $attempts[$username]['count']++;
+ $attempts[$username]['last_attempt'] = time();
+ file_put_contents($this->lockFile, json_encode($attempts));
+ }
+
+ private function clearFailedAttempts(string $username): void
+ {
+ $attempts = $this->getFailedAttempts();
+ unset($attempts[$username]);
+ file_put_contents($this->lockFile, json_encode($attempts));
+ }
+
+ private function getFailedAttempts(): array
+ {
+ if (!file_exists($this->lockFile)) {
+ return [];
+ }
+ $data = json_decode(file_get_contents($this->lockFile), true);
+ return is_array($data) ? $data : [];
+ }
+
+ private function log(string $level, string $message): void
+ {
+ $logFile = $this->config['log_file'];
+ $dir = dirname($logFile);
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+ $timestamp = date('Y-m-d H:i:s');
+ $ip = $_SERVER['REMOTE_ADDR'] ?? 'cli';
+ file_put_contents($logFile, "[{$timestamp}] [{$level}] [{$ip}] {$message}\n", FILE_APPEND);
+ }
+}
diff --git a/admin-console/storage/logs/login_attempts.json b/admin-console/storage/logs/login_attempts.json
new file mode 100644
index 0000000..551b521
--- /dev/null
+++ b/admin-console/storage/logs/login_attempts.json
@@ -0,0 +1 @@
+{"admi":{"count":1,"last_attempt":1771257322}}
\ No newline at end of file
diff --git a/admin-console/templates/layout.php b/admin-console/templates/layout.php
new file mode 100644
index 0000000..5870995
--- /dev/null
+++ b/admin-console/templates/layout.php
@@ -0,0 +1,116 @@
+
+
+
+
+
+ CodePress Admin
+
+
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($message) ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-console/templates/login.php b/admin-console/templates/login.php
new file mode 100644
index 0000000..1a4631e
--- /dev/null
+++ b/admin-console/templates/login.php
@@ -0,0 +1,59 @@
+
+
+
+
+
+ CodePress Admin - Login
+
+
+
+
+
+
+
+
+
diff --git a/admin-console/templates/pages/config.php b/admin-console/templates/pages/config.php
new file mode 100644
index 0000000..911e8cf
--- /dev/null
+++ b/admin-console/templates/pages/config.php
@@ -0,0 +1,19 @@
+ Configuratie
+
+
diff --git a/admin-console/templates/pages/content-edit.php b/admin-console/templates/pages/content-edit.php
new file mode 100644
index 0000000..96e92b5
--- /dev/null
+++ b/admin-console/templates/pages/content-edit.php
@@ -0,0 +1,27 @@
+
+
= htmlspecialchars($fileName) ?>
+
+ Terug
+
+
+
+
+
+
+
+
diff --git a/admin-console/templates/pages/content-new.php b/admin-console/templates/pages/content-new.php
new file mode 100644
index 0000000..c6ec3b2
--- /dev/null
+++ b/admin-console/templates/pages/content-new.php
@@ -0,0 +1,46 @@
+
+
Nieuwe pagina
+
+ Terug
+
+
+
+
+
+
+
+
diff --git a/admin-console/templates/pages/content.php b/admin-console/templates/pages/content.php
new file mode 100644
index 0000000..c0761bc
--- /dev/null
+++ b/admin-console/templates/pages/content.php
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
diff --git a/admin-console/templates/pages/dashboard.php b/admin-console/templates/pages/dashboard.php
new file mode 100644
index 0000000..2d0e0c3
--- /dev/null
+++ b/admin-console/templates/pages/dashboard.php
@@ -0,0 +1,78 @@
+ Dashboard
+
+
+
+
+
+
+
Pagina's
+ = $stats['pages'] ?>
+
+
+
+
+
+
+
+
+
+
Mappen
+ = $stats['directories'] ?>
+
+
+
+
+
+
+
+
+
+
Plugins
+ = $stats['plugins'] ?>
+
+
+
+
+
+
+
+
+
+
Content grootte
+ = $stats['content_size'] ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Site titel | = htmlspecialchars($siteConfig['site_title'] ?? 'CodePress') ?> |
+ | Standaard taal | = htmlspecialchars($siteConfig['language']['default'] ?? 'nl') ?> |
+ | Auteur | = htmlspecialchars($siteConfig['author']['name'] ?? '-') ?> |
+ | PHP versie | = $stats['php_version'] ?> |
+ | Config geladen | = $stats['config_exists'] ? 'Ja' : 'Nee' ?> |
+
+
+
+
+
+
diff --git a/admin-console/templates/pages/plugins.php b/admin-console/templates/pages/plugins.php
new file mode 100644
index 0000000..af09503
--- /dev/null
+++ b/admin-console/templates/pages/plugins.php
@@ -0,0 +1,44 @@
+ Plugins
+
+
+ Geen plugins gevonden in de plugins map.
+
+
+
+
+
+
+
+
+
+ | Hoofdbestand |
+
+ = $plugin['has_main'] ? ' Aanwezig' : ' Ontbreekt' ?>
+ |
+
+
+ | Configuratie |
+
+ = $plugin['has_config'] ? ' Aanwezig' : ' Geen' ?>
+ |
+
+
+ | README |
+
+ = $plugin['has_readme'] ? ' Aanwezig' : ' Geen' ?>
+ |
+
+
+
+
+
+
+
+
diff --git a/admin-console/templates/pages/users.php b/admin-console/templates/pages/users.php
new file mode 100644
index 0000000..d05b8dc
--- /dev/null
+++ b/admin-console/templates/pages/users.php
@@ -0,0 +1,93 @@
+ Gebruikers
+
+
diff --git a/composer.json b/composer.json
index 8c9272a..4ebc5c9 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,8 @@
{
"require": {
"mustache/mustache": "^3.0",
- "league/commonmark": "^2.7"
+ "league/commonmark": "^2.7",
+ "php-mqtt/client": "^2.0",
+ "geoip2/geoip2": "^2.13"
}
}
diff --git a/composer.lock b/composer.lock
index a5a7c25..d098c64 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "3cf7d71c2b61afde676a52c0c83f8bfe",
+ "content-hash": "ca2f778e274e1087d5066837f53bcd23",
"packages": [
{
"name": "dflydev/dot-access-data",
@@ -83,16 +83,16 @@
},
{
"name": "league/commonmark",
- "version": "2.7.1",
+ "version": "2.8.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
+ "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
- "reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
+ "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
"shasum": ""
},
"require": {
@@ -129,7 +129,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.8-dev"
+ "dev-main": "2.9-dev"
}
},
"autoload": {
@@ -186,7 +186,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-20T12:47:49+00:00"
+ "time": "2025-11-26T21:48:24+00:00"
},
{
"name": "league/config",
@@ -323,6 +323,69 @@
},
"time": "2025-06-28T18:28:20+00:00"
},
+ {
+ "name": "myclabs/php-enum",
+ "version": "1.8.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/php-enum.git",
+ "reference": "e7be26966b7398204a234f8673fdad5ac6277802"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802",
+ "reference": "e7be26966b7398204a234f8673fdad5ac6277802",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "php": "^7.3 || ^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5",
+ "squizlabs/php_codesniffer": "1.*",
+ "vimeo/psalm": "^4.6.2 || ^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "MyCLabs\\Enum\\": "src/"
+ },
+ "classmap": [
+ "stubs/Stringable.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP Enum contributors",
+ "homepage": "https://github.com/myclabs/php-enum/graphs/contributors"
+ }
+ ],
+ "description": "PHP Enum implementation",
+ "homepage": "https://github.com/myclabs/php-enum",
+ "keywords": [
+ "enum"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/php-enum/issues",
+ "source": "https://github.com/myclabs/php-enum/tree/1.8.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-01-14T11:49:03+00:00"
+ },
{
"name": "nette/schema",
"version": "v1.3.3",
@@ -390,16 +453,16 @@
},
{
"name": "nette/utils",
- "version": "v4.0.8",
+ "version": "v4.0.9",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
- "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede"
+ "reference": "505a30ad386daa5211f08a318e47015b501cad30"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede",
- "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede",
+ "url": "https://api.github.com/repos/nette/utils/zipball/505a30ad386daa5211f08a318e47015b501cad30",
+ "reference": "505a30ad386daa5211f08a318e47015b501cad30",
"shasum": ""
},
"require": {
@@ -473,9 +536,66 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
- "source": "https://github.com/nette/utils/tree/v4.0.8"
+ "source": "https://github.com/nette/utils/tree/v4.0.9"
},
- "time": "2025-08-06T21:43:34+00:00"
+ "time": "2025-10-31T00:45:47+00:00"
+ },
+ {
+ "name": "php-mqtt/client",
+ "version": "v2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-mqtt/client.git",
+ "reference": "3d141846753a0adee265680ae073cfb9030f2390"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-mqtt/client/zipball/3d141846753a0adee265680ae073cfb9030f2390",
+ "reference": "3d141846753a0adee265680ae073cfb9030f2390",
+ "shasum": ""
+ },
+ "require": {
+ "myclabs/php-enum": "^1.7",
+ "php": "^8.0",
+ "psr/log": "^1.1|^2.0|^3.0"
+ },
+ "require-dev": {
+ "phpunit/php-invoker": "^3.0",
+ "phpunit/phpunit": "^9.0",
+ "squizlabs/php_codesniffer": "^3.5"
+ },
+ "suggest": {
+ "ext-redis": "Required for the RedisRepository"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpMqtt\\Client\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marvin Mall",
+ "email": "marvin-mall@msn.com",
+ "role": "developer"
+ }
+ ],
+ "description": "An MQTT client written in and for PHP.",
+ "keywords": [
+ "client",
+ "mqtt",
+ "publish",
+ "subscribe"
+ ],
+ "support": {
+ "issues": "https://github.com/php-mqtt/client/issues",
+ "source": "https://github.com/php-mqtt/client/tree/v2.3.0"
+ },
+ "time": "2025-09-30T17:53:34+00:00"
},
{
"name": "psr/event-dispatcher",
@@ -527,6 +647,56 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
diff --git a/engine/core/class/CodePressCMS.php b/engine/core/class/CodePressCMS.php
index c2b73e7..785ccf9 100644
--- a/engine/core/class/CodePressCMS.php
+++ b/engine/core/class/CodePressCMS.php
@@ -45,9 +45,7 @@ class CodePressCMS {
$this->currentLanguage = $this->getCurrentLanguage();
$this->translations = $this->loadTranslations($this->currentLanguage);
- // Initialize plugin manager
- require_once __DIR__ . '/../plugin/PluginManager.php';
- require_once __DIR__ . '/../plugin/CMSAPI.php';
+ // Initialize plugin manager (files already loaded in engine/core/index.php)
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins');
$api = new CMSAPI($this);
$this->pluginManager->setAPI($api);
@@ -187,10 +185,10 @@ class CodePressCMS {
if ($item[0] === '.') continue;
// Skip language-specific content that doesn't match current language
- if (preg_match('/^(nl|en)\./', $item)) {
- $langPrefix = substr($item, 0, 2);
- if (($langPrefix === 'nl' && $this->currentLanguage !== 'nl') ||
- ($langPrefix === 'en' && $this->currentLanguage !== 'en')) {
+ $availableLangs = array_keys($this->getAvailableLanguages());
+ $langPattern = '/^(' . implode('|', $availableLangs) . ')\./';
+ if (preg_match($langPattern, $item, $langMatch)) {
+ if ($langMatch[1] !== $this->currentLanguage) {
continue;
}
}
@@ -261,7 +259,7 @@ class CodePressCMS {
$this->searchResults[] = [
'title' => $title,
'path' => $relativePath,
- 'url' => '?page=' . $relativePath,
+ 'url' => '?page=' . $relativePath . '&lang=' . $this->currentLanguage,
'snippet' => $this->createSnippet($content, $query)
];
}
@@ -307,10 +305,6 @@ class CodePressCMS {
}
$page = $_GET['page'] ?? $this->config['default_page'];
- // Sanitize page parameter to prevent XSS
- $page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
- // Prevent path traversal
- $page = str_replace(['../', '..\\', '..'], '', $page);
// Limit length
$page = substr($page, 0, 255);
// Only remove file extension at the end, not all dots
@@ -318,6 +312,13 @@ class CodePressCMS {
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
+ // Prevent path traversal using realpath validation
+ $realContentDir = realpath($this->config['content_dir']);
+ $realFilePath = realpath($filePath);
+ if ($realFilePath && $realContentDir && strpos($realFilePath, $realContentDir) !== 0) {
+ return $this->getError404();
+ }
+
// Check if directory exists FIRST (directories take precedence over files)
if (is_dir($filePath)) {
return $this->getDirectoryListing($pageWithoutExt, $filePath);
@@ -325,13 +326,6 @@ class CodePressCMS {
$actualFilePath = null;
- // Check if directory exists first (directories take precedence over files)
- if (is_dir($filePath)) {
- $directoryResult = $this->getDirectoryListing($pageWithoutExt, $filePath);
-
- return $directoryResult;
- }
-
// Check for exact file matches if no directory found
if (file_exists($filePath . '.md')) {
$actualFilePath = $filePath . '.md';
@@ -509,10 +503,7 @@ class CodePressCMS {
$title = trim($matches[1]);
}
- // Include autoloader
- require_once __DIR__ . '/../../../vendor/autoload.php';
-
- // Configure CommonMark environment
+ // Configure CommonMark environment (autoloader already loaded in bootstrap)
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
@@ -584,7 +575,7 @@ class CodePressCMS {
return $text; // Don't link existing links, current page title, or H1 headings
}
- return '' . $text . '';
+ return '' . $text . '';
};
$content = preg_replace_callback($pattern, $replacement, $content);
@@ -604,11 +595,6 @@ class CodePressCMS {
return $pages;
}
- /**
- * Get all page names from content directory (for navigation)
- *
- * @return array Associative array of page paths to display names
- */
/**
* Recursively scan for page titles in directory
*
@@ -647,37 +633,6 @@ class CodePressCMS {
}
}
- /**
- * Recursively scan for page names in directory (for navigation)
- *
- * @param string $dir Directory to scan
- * @param string $prefix Relative path prefix
- * @param array &$pages Reference to pages array to populate
- * @return void
- */
- private function scanForPageNames($dir, $prefix, &$pages) {
- if (!is_dir($dir)) return;
-
- $items = scandir($dir);
- sort($items);
-
- foreach ($items as $item) {
- if ($item[0] === '.') continue;
-
- $path = $dir . '/' . $item;
- $relativePath = $prefix ? $prefix . '/' . $item : $item;
-
- if (is_dir($path)) {
- $this->scanForPageNames($path, $relativePath, $pages);
- } elseif (preg_match('/\.(md|php|html)$/', $item)) {
- // Use filename without extension as display name
- $displayName = preg_replace('/\.[^.]+$/', '', $item);
- $pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
- $pages[$pagePath] = $this->formatDisplayName($displayName);
- }
- }
- }
-
/**
* Format display name from filename
*
@@ -685,39 +640,33 @@ class CodePressCMS {
* @return string Formatted display name
*/
private function formatDisplayName($filename) {
-
-
- // Remove language prefixes (nl. or en.) from display names
- if (preg_match('/^(nl|en)\.(.+)$/', $filename, $matches)) {
+ // Remove language prefixes dynamically based on available languages
+ $availableLangs = array_keys($this->getAvailableLanguages());
+ $langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
+ if (preg_match($langPattern, $filename, $matches)) {
$filename = $matches[2];
}
- // Remove language prefixes from directory names (nl.php-testen -> php-testen)
- if (preg_match('/^(nl|en)\.php-(.+)$/', $filename, $matches)) {
- $filename = 'php-' . $matches[2];
- }
-
// Remove file extensions (.md, .php, .html) from display names
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
- // Handle special cases first (only for exact filenames, not directories)
- // These should only apply to actual files, not directory names
- if (strtolower($filename) === 'phpinfo' && !preg_match('/\//', $filename)) {
- return 'phpinfo';
- }
- if (strtolower($filename) === 'ict' && !preg_match('/\//', $filename)) {
- return 'ICT';
+ // Handle special cases (case-sensitive display names)
+ $specialCases = [
+ 'phpinfo' => 'phpinfo',
+ 'ict' => 'ICT',
+ ];
+ if (isset($specialCases[strtolower($filename)])) {
+ return $specialCases[strtolower($filename)];
}
- // Replace hyphens and underscores with spaces
+ // Replace hyphens and underscores with spaces, then title case
$name = str_replace(['-', '_'], ' ', $filename);
-
- // Convert to title case (first letter uppercase, rest lowercase)
$name = ucwords(strtolower($name));
- // Handle other special cases
- $name = str_replace('Phpinfo', 'phpinfo', $name);
- $name = str_replace('Ict', 'ICT', $name);
+ // Post-process special cases in compound names
+ foreach ($specialCases as $lower => $correct) {
+ $name = str_ireplace(ucfirst($lower), $correct, $name);
+ }
return $name;
}
@@ -866,10 +815,7 @@ private function getGuidePage() {
$metadata = $parsed['metadata'];
$contentWithoutMeta = $parsed['content'];
- // Include autoloader
- require_once __DIR__ . '/../../../vendor/autoload.php';
-
- // Configure CommonMark environment
+ // Configure CommonMark environment (autoloader already loaded in bootstrap)
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
@@ -993,8 +939,6 @@ private function getGuidePage() {
$hasContent = true;
}
- $content .= '';
-
if (!$hasContent) {
$content .= '
' . $this->t('directory_empty') . '.
';
}
@@ -1167,8 +1111,11 @@ private function getGuidePage() {
* @return string Breadcrumb HTML
*/
public function generateBreadcrumb() {
+ // Sidebar toggle button (shown before home icon in breadcrumb)
+ $sidebarToggle = '';
+
if (isset($_GET['search'])) {
- return '';
+ return '';
}
$page = $_GET['page'] ?? $this->config['default_page'];
@@ -1176,12 +1123,13 @@ private function getGuidePage() {
$page = preg_replace('/\.[^.]+$/', '', $page);
if ($page === $this->config['default_page']) {
- return '';
+ return '';
}
$breadcrumb = '