diff --git a/README.en.md b/README.en.md index ff2cbae..bbd7b07 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 141f12d..d7183fb 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.md)** - Gedetailleerde handleiding diff --git a/admin-console/composer.json b/admin-console/composer.json deleted file mode 100644 index e6fbb08..0000000 --- a/admin-console/composer.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "codepress/admin-console", - "description": "Admin Console for CodePress CMS", - "type": "project", - "require": { - "php": ">=8.4", - "firebase/php-jwt": "^6.10", - "phpmailer/phpmailer": "^6.9", - "monolog/monolog": "^3.5" - }, - "require-dev": { - "phpunit/phpunit": "^11.0", - "squizlabs/php_codesniffer": "^3.10" - }, - "autoload": { - "psr-4": { - "CodePress\\Admin\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "CodePress\\Admin\\Tests\\": "tests/" - } - }, - "scripts": { - "start": "php -S localhost:8081 -t public", - "test": "phpunit", - "lint": "phpcs --standard=PSR12 src/", - "lint-fix": "phpcbf --standard=PSR12 src/" - }, - "license": "MIT", - "authors": [ - { - "name": "Edwin Noorlander", - "email": "edwin@noorlander.info" - } - ], - "minimum-stability": "stable", - "prefer-stable": true -} \ No newline at end of file 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 index 9aa6c2a..5bebf66 100644 --- a/admin-console/config/app.php +++ b/admin-console/config/app.php @@ -1,57 +1,17 @@ 'CodePress Admin Console', + 'name' => 'CodePress Admin', 'version' => '1.0.0', 'debug' => $_ENV['APP_DEBUG'] ?? false, 'timezone' => 'Europe/Amsterdam', - // Security - 'security' => [ - 'jwt_secret' => $_ENV['JWT_SECRET'] ?? throw new \RuntimeException('JWT_SECRET environment variable must be set'), - 'jwt_expiration' => 3600, // 1 hour - 'session_timeout' => 1800, // 30 minutes - 'max_login_attempts' => 5, - 'lockout_duration' => 900, // 15 minutes - ], - - // Database - 'database' => [ - 'type' => 'sqlite', - 'path' => __DIR__ . '/../database/admin.db', - 'backup_path' => __DIR__ . '/../storage/backups/', - ], - - // CodePress Integration - 'codepress' => [ - 'path' => __DIR__ . '/../../', - 'content_dir' => __DIR__ . '/../../public/content/', - 'templates_dir' => __DIR__ . '/../../engine/templates/', - 'plugins_dir' => __DIR__ . '/../../plugins/', - ], - - // Email - 'mail' => [ - 'host' => $_ENV['MAIL_HOST'] ?? 'localhost', - 'port' => $_ENV['MAIL_PORT'] ?? 587, - 'username' => $_ENV['MAIL_USERNAME'] ?? '', - 'password' => $_ENV['MAIL_PASSWORD'] ?? '', - 'from' => $_ENV['MAIL_FROM'] ?? 'admin@codepress.local', - 'from_name' => 'CodePress Admin', - ], - - // Storage - 'storage' => [ - 'uploads_path' => __DIR__ . '/../storage/uploads/', - 'logs_path' => __DIR__ . '/../storage/logs/', - 'cache_path' => __DIR__ . '/../storage/cache/', - ], - - // UI Settings - 'ui' => [ - 'theme' => 'bootstrap', - 'items_per_page' => 20, - 'date_format' => 'd-m-Y H:i', - 'timezone' => 'Europe/Amsterdam', - ], -]; \ No newline at end of file + // 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/src/Controllers/AuthController.php b/admin-console/src/Controllers/AuthController.php deleted file mode 100644 index dd2bd91..0000000 --- a/admin-console/src/Controllers/AuthController.php +++ /dev/null @@ -1,80 +0,0 @@ -authService = new AuthService(); - $this->logger = new LoggerService(); - } - - public function login() { - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $username = $_POST['username'] ?? ''; - $password = $_POST['password'] ?? ''; - $remember = isset($_POST['remember']); - - $result = $this->authService->login($username, $password, $remember); - - if ($result['success']) { - $this->logger->info("User logged in: {$username}"); - $this->jsonResponse(['success' => true, 'redirect' => '/admin/dashboard']); - } else { - $this->logger->warning("Failed login attempt: {$username}"); - $this->jsonResponse(['success' => false, 'message' => $result['message']]); - } - } - - $this->renderView('auth/login'); - } - - public function logout() { - $this->authService->logout(); - $this->logger->info("User logged out"); - header('Location: /admin/login'); - exit; - } - - public function profile() { - if (!$this->authService->isAuthenticated()) { - header('Location: /admin/login'); - exit; - } - - $user = $this->authService->getCurrentUser(); - - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $email = $_POST['email'] ?? ''; - $currentPassword = $_POST['current_password'] ?? ''; - $newPassword = $_POST['new_password'] ?? ''; - - $result = $this->authService->updateProfile($user['id'], $email, $currentPassword, $newPassword); - - if ($result['success']) { - $this->logger->info("Profile updated: {$user['username']}"); - $this->jsonResponse(['success' => true, 'message' => 'Profile updated successfully']); - } else { - $this->jsonResponse(['success' => false, 'message' => $result['message']]); - } - } - - $this->renderView('auth/profile', ['user' => $user]); - } - - private function jsonResponse(array $data) { - header('Content-Type: application/json'); - echo json_encode($data); - exit; - } - - private function renderView(string $view, array $data = []) { - extract($data); - require __DIR__ . "/../../public/templates/{$view}.php"; - } -} \ No newline at end of file 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/guide/en.codepress.md b/guide/en.codepress.md index bf3e8ee..1977c24 100644 --- a/guide/en.codepress.md +++ b/guide/en.codepress.md @@ -50,6 +50,17 @@ CodePress is a lightweight, file-based Content Management System built with PHP - **Dynamic layouts** with YAML frontmatter - **Sidebar support** with plugin integration and toggle function via breadcrumb +### 🛡️ Admin Console +- Built-in admin panel at `/admin.php` +- **Dashboard** with statistics and quick actions +- **Content management** - Browse, create, edit and delete files +- **Configuration editor** - Edit `config.json` with JSON validation +- **Plugin overview** - Status of all installed plugins +- **User management** - Add, remove users and change passwords +- Session-based authentication with bcrypt hashing +- CSRF protection, brute-force lockout (5 attempts, 15 min) +- Default login: `admin` / `admin` (change immediately after installation) + ## Installation 1. Clone or download CodePress files diff --git a/guide/nl.codepress.md b/guide/nl.codepress.md index a78d4a5..7f47544 100644 --- a/guide/nl.codepress.md +++ b/guide/nl.codepress.md @@ -50,6 +50,17 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd - **Dynamic layouts** met YAML frontmatter - **Sidebar support** met plugin integratie en toggle functie via breadcrumb +### 🛡️ Admin Console +- Ingebouwd admin paneel op `/admin.php` +- **Dashboard** met statistieken en snelle acties +- **Content beheer** - Bestanden browsen, aanmaken, bewerken en verwijderen +- **Configuratie editor** - `config.json` bewerken met JSON-validatie +- **Plugin overzicht** - Status van alle geinstalleerde plugins +- **Gebruikersbeheer** - Gebruikers toevoegen, verwijderen, wachtwoorden wijzigen +- Session-based authenticatie met bcrypt hashing +- CSRF-bescherming, brute-force lockout (5 pogingen, 15 min) +- Standaard login: `admin` / `admin` (wijzig direct na installatie) + ## Installatie 1. Upload bestanden naar webserver diff --git a/public/admin.php b/public/admin.php new file mode 100644 index 0000000..e259fba --- /dev/null +++ b/public/admin.php @@ -0,0 +1,449 @@ +isAuthenticated()) { + header('Location: admin.php?route=login'); + exit; +} + +// Authenticated routes +switch ($route) { + case 'logout': + $auth->logout(); + header('Location: admin.php?route=login'); + exit; + + case 'dashboard': + case '': + handleDashboard($auth, $appConfig); + break; + + case 'content': + handleContent($auth, $appConfig); + break; + + case 'content-edit': + handleContentEdit($auth, $appConfig); + break; + + case 'content-new': + handleContentNew($auth, $appConfig); + break; + + case 'content-delete': + handleContentDelete($auth, $appConfig); + break; + + case 'config': + handleConfig($auth, $appConfig); + break; + + case 'plugins': + handlePlugins($auth, $appConfig); + break; + + case 'users': + handleUsers($auth, $appConfig); + break; + + default: + header('Location: admin.php?route=dashboard'); + exit; +} + +// --- Route Handlers --- + +function handleLogin(AdminAuth $auth): void +{ + $error = ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + + $result = $auth->login($username, $password); + if ($result['success']) { + header('Location: admin.php?route=dashboard'); + exit; + } + $error = $result['message']; + } + + require __DIR__ . '/../admin-console/templates/login.php'; +} + +function handleDashboard(AdminAuth $auth, array $config): void +{ + $user = $auth->getCurrentUser(); + $csrf = $auth->getCsrfToken(); + + // Gather stats + $contentDir = $config['content_dir']; + $pluginsDir = $config['plugins_dir']; + $configJson = $config['config_json']; + + $stats = [ + 'pages' => countFiles($contentDir, ['md', 'php', 'html']), + 'directories' => countDirs($contentDir), + 'plugins' => countDirs($pluginsDir), + 'config_exists' => file_exists($configJson), + 'content_size' => formatSize(dirSize($contentDir)), + 'php_version' => PHP_VERSION, + ]; + + // Load site config + $siteConfig = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : []; + + require __DIR__ . '/../admin-console/templates/layout.php'; +} + +function handleContent(AdminAuth $auth, array $config): void +{ + $user = $auth->getCurrentUser(); + $csrf = $auth->getCsrfToken(); + $contentDir = $config['content_dir']; + $subdir = $_GET['dir'] ?? ''; + + // Prevent path traversal + $subdir = str_replace(['../', '..\\'], '', $subdir); + $fullPath = rtrim($contentDir, '/') . '/' . $subdir; + + if (!is_dir($fullPath)) { + $fullPath = $contentDir; + $subdir = ''; + } + + $items = scanContentDir($fullPath, $subdir); + $route = 'content'; + + require __DIR__ . '/../admin-console/templates/layout.php'; +} + +function handleContentEdit(AdminAuth $auth, array $config): void +{ + $user = $auth->getCurrentUser(); + $csrf = $auth->getCsrfToken(); + $contentDir = $config['content_dir']; + $file = $_GET['file'] ?? ''; + $file = str_replace(['../', '..\\'], '', $file); + $filePath = rtrim($contentDir, '/') . '/' . $file; + $message = ''; + $messageType = ''; + + // Validate path + $realPath = realpath($filePath); + $realContentDir = realpath($contentDir); + if (!$realPath || !$realContentDir || strpos($realPath, $realContentDir) !== 0) { + header('Location: admin.php?route=content'); + exit; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { + $message = 'Ongeldige CSRF token.'; + $messageType = 'danger'; + } else { + $content = $_POST['content'] ?? ''; + file_put_contents($filePath, $content); + $message = 'Bestand opgeslagen.'; + $messageType = 'success'; + } + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + $fileExt = pathinfo($filePath, PATHINFO_EXTENSION); + $route = 'content-edit'; + + require __DIR__ . '/../admin-console/templates/layout.php'; +} + +function handleContentNew(AdminAuth $auth, array $config): void +{ + $user = $auth->getCurrentUser(); + $csrf = $auth->getCsrfToken(); + $contentDir = $config['content_dir']; + $dir = $_GET['dir'] ?? ''; + $dir = str_replace(['../', '..\\'], '', $dir); + $message = ''; + $messageType = ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { + $message = 'Ongeldige CSRF token.'; + $messageType = 'danger'; + } else { + $filename = trim($_POST['filename'] ?? ''); + $content = $_POST['content'] ?? ''; + $type = $_POST['type'] ?? 'md'; + + if (empty($filename)) { + $message = 'Bestandsnaam is verplicht.'; + $messageType = 'danger'; + } else { + // Sanitize filename + $filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $filename); + if (!preg_match('/\.(md|php|html)$/', $filename)) { + $filename .= '.' . $type; + } + $targetDir = rtrim($contentDir, '/') . '/' . $dir; + $filePath = $targetDir . '/' . $filename; + + if (file_exists($filePath)) { + $message = 'Bestand bestaat al.'; + $messageType = 'danger'; + } else { + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + file_put_contents($filePath, $content); + header('Location: admin.php?route=content&dir=' . urlencode($dir)); + exit; + } + } + } + } + + $route = 'content-new'; + require __DIR__ . '/../admin-console/templates/layout.php'; +} + +function handleContentDelete(AdminAuth $auth, array $config): void +{ + $contentDir = $config['content_dir']; + $file = $_GET['file'] ?? ''; + $file = str_replace(['../', '..\\'], '', $file); + $filePath = rtrim($contentDir, '/') . '/' . $file; + + $realPath = realpath($filePath); + $realContentDir = realpath($contentDir); + + if ($_SERVER['REQUEST_METHOD'] === 'POST' + && $auth->verifyCsrf($_POST['csrf_token'] ?? '') + && $realPath && $realContentDir + && strpos($realPath, $realContentDir) === 0 + ) { + if (is_file($filePath)) { + unlink($filePath); + } + } + + $dir = dirname($file); + header('Location: admin.php?route=content&dir=' . urlencode($dir === '.' ? '' : $dir)); + exit; +} + +function handleConfig(AdminAuth $auth, array $config): void +{ + $user = $auth->getCurrentUser(); + $csrf = $auth->getCsrfToken(); + $configJson = $config['config_json']; + $message = ''; + $messageType = ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { + $message = 'Ongeldige CSRF token.'; + $messageType = 'danger'; + } else { + $jsonContent = $_POST['config_content'] ?? ''; + $parsed = json_decode($jsonContent, true); + if ($parsed === null && json_last_error() !== JSON_ERROR_NONE) { + $message = 'Ongeldige JSON: ' . json_last_error_msg(); + $messageType = 'danger'; + } else { + file_put_contents($configJson, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $message = 'Configuratie opgeslagen.'; + $messageType = 'success'; + $jsonContent = null; // reload from file + } + } + } + + $siteConfig = file_exists($configJson) ? file_get_contents($configJson) : '{}'; + if (isset($jsonContent)) { + $siteConfig = $jsonContent; + } + + $route = 'config'; + require __DIR__ . '/../admin-console/templates/layout.php'; +} + +function handlePlugins(AdminAuth $auth, array $config): void +{ + $user = $auth->getCurrentUser(); + $csrf = $auth->getCsrfToken(); + $pluginsDir = $config['plugins_dir']; + $plugins = []; + + if (is_dir($pluginsDir)) { + foreach (scandir($pluginsDir) as $item) { + if ($item[0] === '.') continue; + $pluginPath = $pluginsDir . '/' . $item; + if (!is_dir($pluginPath)) continue; + + $hasConfig = file_exists($pluginPath . '/config.json'); + $pluginConfig = $hasConfig ? json_decode(file_get_contents($pluginPath . '/config.json'), true) : []; + $hasMainFile = file_exists($pluginPath . '/' . $item . '.php'); + $hasReadme = file_exists($pluginPath . '/README.md'); + + $plugins[] = [ + 'name' => $item, + 'path' => $pluginPath, + 'enabled' => $pluginConfig['enabled'] ?? true, + 'config' => $pluginConfig, + 'has_config' => $hasConfig, + 'has_main' => $hasMainFile, + 'has_readme' => $hasReadme, + ]; + } + } + + $route = 'plugins'; + require __DIR__ . '/../admin-console/templates/layout.php'; +} + +function handleUsers(AdminAuth $auth, array $config): void +{ + $user = $auth->getCurrentUser(); + $csrf = $auth->getCsrfToken(); + $message = ''; + $messageType = ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { + $message = 'Ongeldige CSRF token.'; + $messageType = 'danger'; + } else { + $action = $_POST['action'] ?? ''; + + if ($action === 'add') { + $result = $auth->addUser( + trim($_POST['username'] ?? ''), + $_POST['password'] ?? '', + $_POST['role'] ?? 'admin' + ); + $message = $result['message']; + $messageType = $result['success'] ? 'success' : 'danger'; + } elseif ($action === 'delete') { + $result = $auth->deleteUser($_POST['delete_username'] ?? ''); + $message = $result['message']; + $messageType = $result['success'] ? 'success' : 'danger'; + } elseif ($action === 'change_password') { + $result = $auth->changePassword( + $_POST['pw_username'] ?? '', + $_POST['new_password'] ?? '' + ); + $message = $result['message']; + $messageType = $result['success'] ? 'success' : 'danger'; + } + } + } + + $users = $auth->getUsers(); + $route = 'users'; + require __DIR__ . '/../admin-console/templates/layout.php'; +} + +// --- Helper functions --- + +function countFiles(string $dir, array $extensions): int +{ + $count = 0; + if (!is_dir($dir)) return 0; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->isFile() && in_array($file->getExtension(), $extensions)) { + $count++; + } + } + return $count; +} + +function countDirs(string $dir): int +{ + if (!is_dir($dir)) return 0; + $count = 0; + foreach (scandir($dir) as $item) { + if ($item[0] !== '.' && is_dir($dir . '/' . $item)) $count++; + } + return $count; +} + +function dirSize(string $dir): int +{ + $size = 0; + if (!is_dir($dir)) return 0; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->isFile()) $size += $file->getSize(); + } + return $size; +} + +function formatSize(int $bytes): string +{ + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + return round($bytes, 1) . ' ' . $units[$i]; +} + +function scanContentDir(string $fullPath, string $subdir): array +{ + $items = []; + if (!is_dir($fullPath)) return $items; + + foreach (scandir($fullPath) as $item) { + if ($item[0] === '.') continue; + $itemPath = $fullPath . '/' . $item; + $relativePath = $subdir ? $subdir . '/' . $item : $item; + + $items[] = [ + 'name' => $item, + 'path' => $relativePath, + 'is_dir' => is_dir($itemPath), + 'size' => is_file($itemPath) ? formatSize(filesize($itemPath)) : '', + 'modified' => date('d-m-Y H:i', filemtime($itemPath)), + 'extension' => is_file($itemPath) ? pathinfo($item, PATHINFO_EXTENSION) : '', + ]; + } + + // Sort: directories first, then files alphabetically + usort($items, function ($a, $b) { + if ($a['is_dir'] !== $b['is_dir']) return $b['is_dir'] - $a['is_dir']; + return strcasecmp($a['name'], $b['name']); + }); + + return $items; +}