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 + + + + + + + + + +
+ + + + + +
+ + + + 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 + + + + + +
+
+ +
+
+ + + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+
+
+

+ Terug naar website +

+
+
+ + + 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

+ +
+
+ config.json +
+
+
+ +
+ + Bewerk de JSON configuratie. Ongeldige JSON wordt niet opgeslagen. +
+ +
+
+
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 @@ +
+

+ + Terug + +
+ +
+
+
+ +
+
+ + +
+ +
+
+ + Annuleren +
+
+
+
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 + +
+ +
+
+
+ +
+
+ + + Extensie wordt automatisch toegevoegd. +
+
+ + +
+
+ +
+ Map: +
+ +
+ + +
+
+ + Annuleren +
+
+
+
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 @@ +
+

Content

+ + Nieuw bestand + +
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NaamTypeGrootteGewijzigdActies
Geen bestanden gevonden.
+ + + + + + + 'bi-file-text text-primary', + 'php' => 'bi-file-code text-success', + 'html' => 'bi-file-earmark text-info', + default => 'bi-file text-muted' + }; + ?> + + + + + + Map + + + + + + + + +
+ + +
+ +
+
+
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
+

+
+ +
+
+
+
+
+
+
+
Mappen
+

+
+ +
+
+
+
+
+
+
+
Plugins
+

+
+ +
+
+
+
+
+
+
+
Content grootte
+

+
+ +
+
+
+
+ +
+
+
+
Site informatie
+
+ + + + + + +
Site titel
Standaard taal
Auteur
PHP versie
Config geladenJa' : '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.
+ +
+ +
+
+
+ + + Actief + + Inactief + +
+
+ + + + + + + + + + + + + +
Hoofdbestand + Aanwezig' : ' Ontbreekt' ?> +
Configuratie + Aanwezig' : ' Geen' ?> +
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

+ +
+ +
+
+
Huidige gebruikers
+
+ + + + + + + + + + + + + + + + + + + +
GebruikersnaamRolAangemaaktActies
+ + + + Jij + + + +
+ + + +
+ + +
+
+ +
+ + + + +
+ +
+
+
+
+ + +
+
+
Gebruiker toevoegen
+
+
+ + +
+ + +
+
+ + + Minimaal 8 tekens. +
+
+ + +
+ +
+
+
+
+
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 = '