Add admin console with login, dashboard, content/config/plugin/user management

File-based admin panel accessible at /admin.php with:
- Session-based auth with bcrypt hashing and brute-force protection
- Dashboard with site statistics and quick actions
- Content manager: browse, create, edit, delete files
- Config editor with JSON validation
- Plugin overview with status indicators
- User management: add, remove, change passwords
- CSRF protection on all forms, path traversal prevention
- Updated README (NL/EN) and guides with admin documentation
This commit is contained in:
2026-02-16 17:01:02 +01:00
parent 1cd9c8841d
commit 8e18a5d87a
20 changed files with 1420 additions and 172 deletions

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -1,57 +1,17 @@
<?php
return [
'name' => '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',
],
];
// 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',
];

View File

@@ -0,0 +1,279 @@
<?php
/**
* AdminAuth - File-based authentication for CodePress Admin
*/
class AdminAuth
{
private array $config;
private array $adminConfig;
private string $lockFile;
public function __construct(array $appConfig)
{
$this->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);
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace CodePress\Admin\Controllers;
use CodePress\Admin\Services\AuthService;
use CodePress\Admin\Services\LoggerService;
class AuthController {
private AuthService $authService;
private LoggerService $logger;
public function __construct() {
$this->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";
}
}

View File

@@ -0,0 +1 @@
{"admi":{"count":1,"last_attempt":1771257322}}

View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CodePress Admin</title>
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<link rel="stylesheet" href="assets/css/bootstrap-icons.css">
<style>
body { background-color: #f5f6fa; min-height: 100vh; }
.admin-sidebar { background-color: #0a369d; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; }
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
.admin-sidebar .nav-link i { width: 24px; text-align: center; margin-right: 0.5rem; }
.admin-main { margin-left: 240px; padding: 2rem; }
.admin-brand { color: #fff; padding: 1.25rem; font-size: 1.1rem; border-bottom: 1px solid rgba(255,255,255,0.15); }
.admin-brand i { margin-right: 0.5rem; }
.stat-card { border: none; border-radius: 0.5rem; }
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); position: absolute; bottom: 0; width: 100%; }
@media (max-width: 768px) {
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
.admin-main { margin-left: 0; }
}
</style>
</head>
<body>
<!-- Sidebar -->
<nav class="admin-sidebar d-flex flex-column">
<div class="admin-brand">
<i class="bi bi-gear-fill"></i> CodePress Admin
</div>
<ul class="nav flex-column mt-2">
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'dashboard' || ($route ?? '') === '' ? 'active' : '' ?>" href="admin.php?route=dashboard">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'content' || str_starts_with($route ?? '', 'content') ? 'active' : '' ?>" href="admin.php?route=content">
<i class="bi bi-file-earmark-text"></i> Content
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'config' ? 'active' : '' ?>" href="admin.php?route=config">
<i class="bi bi-sliders"></i> Configuratie
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'plugins' ? 'active' : '' ?>" href="admin.php?route=plugins">
<i class="bi bi-plug"></i> Plugins
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'users' ? 'active' : '' ?>" href="admin.php?route=users">
<i class="bi bi-people"></i> Gebruikers
</a>
</li>
<li class="nav-item mt-3">
<a class="nav-link" href="index.php" target="_blank">
<i class="bi bi-box-arrow-up-right"></i> Website bekijken
</a>
</li>
<li class="nav-item">
<a class="nav-link text-warning" href="admin.php?route=logout">
<i class="bi bi-box-arrow-left"></i> Uitloggen
</a>
</li>
</ul>
<div class="admin-user">
<i class="bi bi-person-circle"></i> <?= htmlspecialchars($user['username'] ?? '') ?>
</div>
</nav>
<!-- Main content -->
<main class="admin-main">
<?php if (!empty($message)): ?>
<div class="alert alert-<?= $messageType ?? 'info' ?> alert-dismissible fade show" role="alert">
<?= htmlspecialchars($message) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php
$currentRoute = $route ?? 'dashboard';
switch ($currentRoute) {
case 'dashboard':
case '':
require __DIR__ . '/pages/dashboard.php';
break;
case 'content':
require __DIR__ . '/pages/content.php';
break;
case 'content-edit':
require __DIR__ . '/pages/content-edit.php';
break;
case 'content-new':
require __DIR__ . '/pages/content-new.php';
break;
case 'config':
require __DIR__ . '/pages/config.php';
break;
case 'plugins':
require __DIR__ . '/pages/plugins.php';
break;
case 'users':
require __DIR__ . '/pages/users.php';
break;
}
?>
</main>
<script src="assets/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CodePress Admin - Login</title>
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<link rel="stylesheet" href="assets/css/bootstrap-icons.css">
<style>
body { background-color: #f5f6fa; }
.login-card { max-width: 400px; margin: 10vh auto; }
.login-header { background-color: #0a369d; color: #fff; padding: 2rem; text-align: center; border-radius: 0.5rem 0.5rem 0 0; }
</style>
</head>
<body>
<div class="container">
<div class="login-card">
<div class="login-header">
<h4><i class="bi bi-shield-lock"></i> CodePress Admin</h4>
</div>
<div class="card border-0 shadow-sm" style="border-radius: 0 0 0.5rem 0.5rem;">
<div class="card-body p-4">
<?php if (!empty($error)): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= htmlspecialchars($error) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<form method="POST" action="admin.php?route=login">
<div class="mb-3">
<label for="username" class="form-label">Gebruikersnaam</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Wachtwoord</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="password" name="password" required>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right"></i> Inloggen
</button>
</div>
</form>
</div>
</div>
<p class="text-center text-muted mt-3 small">
<a href="index.php" class="text-decoration-none"><i class="bi bi-arrow-left"></i> Terug naar website</a>
</p>
</div>
</div>
<script src="assets/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<h2 class="mb-4"><i class="bi bi-sliders"></i> Configuratie</h2>
<div class="card shadow-sm">
<div class="card-header">
<i class="bi bi-filetype-json"></i> config.json
</div>
<div class="card-body">
<form method="POST" action="admin.php?route=config">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="mb-3">
<textarea name="config_content" class="form-control font-monospace" rows="25" style="font-size: 0.9rem; tab-size: 4;"><?= htmlspecialchars($siteConfig) ?></textarea>
<small class="form-text text-muted">Bewerk de JSON configuratie. Ongeldige JSON wordt niet opgeslagen.</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Opslaan
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-pencil"></i> <?= htmlspecialchars($fileName) ?></h2>
<a href="admin.php?route=content&dir=<?= urlencode(dirname($_GET['file'] ?? '')) ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Terug
</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="admin.php?route=content-edit&file=<?= urlencode($_GET['file'] ?? '') ?>">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="badge bg-secondary"><?= strtoupper($fileExt) ?></span>
<small class="text-muted"><?= htmlspecialchars($_GET['file'] ?? '') ?></small>
</div>
<textarea name="content" class="form-control font-monospace" rows="25" style="font-size: 0.9rem; tab-size: 4;"><?= htmlspecialchars($fileContent) ?></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Opslaan
</button>
<a href="admin.php?route=content&dir=<?= urlencode(dirname($_GET['file'] ?? '')) ?>" class="btn btn-outline-secondary">Annuleren</a>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,46 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-plus-lg"></i> Nieuwe pagina</h2>
<a href="admin.php?route=content&dir=<?= urlencode($dir ?? '') ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Terug
</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="admin.php?route=content-new&dir=<?= urlencode($dir ?? '') ?>">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="row mb-3">
<div class="col-md-8">
<label for="filename" class="form-label">Bestandsnaam</label>
<input type="text" class="form-control" id="filename" name="filename" placeholder="bijv. mijn-pagina" required>
<small class="form-text text-muted">Extensie wordt automatisch toegevoegd.</small>
</div>
<div class="col-md-4">
<label for="type" class="form-label">Type</label>
<select class="form-select" id="type" name="type">
<option value="md" selected>Markdown (.md)</option>
<option value="php">PHP (.php)</option>
<option value="html">HTML (.html)</option>
</select>
</div>
</div>
<?php if (!empty($dir)): ?>
<div class="mb-3">
<small class="text-muted">Map: <?= htmlspecialchars($dir) ?></small>
</div>
<?php endif; ?>
<div class="mb-3">
<label for="content" class="form-label">Inhoud</label>
<textarea name="content" id="content" class="form-control font-monospace" rows="20" style="font-size: 0.9rem; tab-size: 4;" placeholder="# Mijn nieuwe pagina
Schrijf hier je inhoud..."></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Aanmaken
</button>
<a href="admin.php?route=content&dir=<?= urlencode($dir ?? '') ?>" class="btn btn-outline-secondary">Annuleren</a>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-file-earmark-text"></i> Content</h2>
<a href="admin.php?route=content-new&dir=<?= urlencode($subdir) ?>" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg"></i> Nieuw bestand
</a>
</div>
<?php if (!empty($subdir)): ?>
<?php
$parentDir = dirname($subdir);
$parentLink = $parentDir === '.' ? '' : $parentDir;
?>
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="admin.php?route=content"><i class="bi bi-house"></i></a></li>
<?php
$crumbPath = '';
foreach (explode('/', $subdir) as $i => $crumb):
$crumbPath .= ($crumbPath ? '/' : '') . $crumb;
?>
<li class="breadcrumb-item <?= $crumbPath === $subdir ? 'active' : '' ?>">
<?php if ($crumbPath === $subdir): ?>
<?= htmlspecialchars($crumb) ?>
<?php else: ?>
<a href="admin.php?route=content&dir=<?= urlencode($crumbPath) ?>"><?= htmlspecialchars($crumb) ?></a>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</nav>
<?php endif; ?>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Naam</th>
<th>Type</th>
<th>Grootte</th>
<th>Gewijzigd</th>
<th style="width: 120px;">Acties</th>
</tr>
</thead>
<tbody>
<?php if (empty($items)): ?>
<tr><td colspan="5" class="text-muted text-center py-4">Geen bestanden gevonden.</td></tr>
<?php else: ?>
<?php foreach ($items as $item): ?>
<tr>
<td>
<?php if ($item['is_dir']): ?>
<a href="admin.php?route=content&dir=<?= urlencode($item['path']) ?>">
<i class="bi bi-folder-fill text-warning"></i> <?= htmlspecialchars($item['name']) ?>
</a>
<?php else: ?>
<a href="admin.php?route=content-edit&file=<?= urlencode($item['path']) ?>">
<?php
$icon = match($item['extension']) {
'md' => 'bi-file-text text-primary',
'php' => 'bi-file-code text-success',
'html' => 'bi-file-earmark text-info',
default => 'bi-file text-muted'
};
?>
<i class="bi <?= $icon ?>"></i> <?= htmlspecialchars($item['name']) ?>
</a>
<?php endif; ?>
</td>
<td>
<?php if ($item['is_dir']): ?>
<span class="badge bg-warning text-dark">Map</span>
<?php else: ?>
<span class="badge bg-secondary"><?= strtoupper($item['extension']) ?></span>
<?php endif; ?>
</td>
<td class="text-muted"><?= $item['size'] ?></td>
<td class="text-muted"><?= $item['modified'] ?></td>
<td>
<?php if (!$item['is_dir']): ?>
<a href="admin.php?route=content-edit&file=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-primary" title="Bewerken">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="admin.php?route=content-delete&file=<?= urlencode($item['path']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je dit bestand wilt verwijderen?')">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
<i class="bi bi-trash"></i>
</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,78 @@
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> Dashboard</h2>
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Pagina's</h6>
<h3 class="mb-0"><?= $stats['pages'] ?></h3>
</div>
<i class="bi bi-file-earmark-text stat-icon text-primary"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Mappen</h6>
<h3 class="mb-0"><?= $stats['directories'] ?></h3>
</div>
<i class="bi bi-folder stat-icon text-warning"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Plugins</h6>
<h3 class="mb-0"><?= $stats['plugins'] ?></h3>
</div>
<i class="bi bi-plug stat-icon text-success"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Content grootte</h6>
<h3 class="mb-0"><?= $stats['content_size'] ?></h3>
</div>
<i class="bi bi-hdd stat-icon text-info"></i>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-info-circle"></i> Site informatie</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">Site titel</td><td><?= htmlspecialchars($siteConfig['site_title'] ?? 'CodePress') ?></td></tr>
<tr><td class="text-muted">Standaard taal</td><td><?= htmlspecialchars($siteConfig['language']['default'] ?? 'nl') ?></td></tr>
<tr><td class="text-muted">Auteur</td><td><?= htmlspecialchars($siteConfig['author']['name'] ?? '-') ?></td></tr>
<tr><td class="text-muted">PHP versie</td><td><?= $stats['php_version'] ?></td></tr>
<tr><td class="text-muted">Config geladen</td><td><?= $stats['config_exists'] ? '<span class="badge bg-success">Ja</span>' : '<span class="badge bg-danger">Nee</span>' ?></td></tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-lightning"></i> Snelle acties</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="admin.php?route=content-new" class="btn btn-outline-primary"><i class="bi bi-plus-lg"></i> Nieuwe pagina</a>
<a href="admin.php?route=config" class="btn btn-outline-secondary"><i class="bi bi-sliders"></i> Configuratie bewerken</a>
<a href="admin.php?route=content" class="btn btn-outline-info"><i class="bi bi-folder2-open"></i> Content beheren</a>
<a href="index.php" target="_blank" class="btn btn-outline-success"><i class="bi bi-box-arrow-up-right"></i> Website bekijken</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,44 @@
<h2 class="mb-4"><i class="bi bi-plug"></i> Plugins</h2>
<?php if (empty($plugins)): ?>
<div class="alert alert-info">Geen plugins gevonden in de plugins map.</div>
<?php else: ?>
<div class="row g-4">
<?php foreach ($plugins as $plugin): ?>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-plug"></i> <?= htmlspecialchars($plugin['name']) ?></strong>
<?php if ($plugin['enabled']): ?>
<span class="badge bg-success">Actief</span>
<?php else: ?>
<span class="badge bg-secondary">Inactief</span>
<?php endif; ?>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr>
<td class="text-muted">Hoofdbestand</td>
<td>
<?= $plugin['has_main'] ? '<i class="bi bi-check-circle text-success"></i> Aanwezig' : '<i class="bi bi-x-circle text-danger"></i> Ontbreekt' ?>
</td>
</tr>
<tr>
<td class="text-muted">Configuratie</td>
<td>
<?= $plugin['has_config'] ? '<i class="bi bi-check-circle text-success"></i> Aanwezig' : '<i class="bi bi-dash-circle text-muted"></i> Geen' ?>
</td>
</tr>
<tr>
<td class="text-muted">README</td>
<td>
<?= $plugin['has_readme'] ? '<i class="bi bi-check-circle text-success"></i> Aanwezig' : '<i class="bi bi-dash-circle text-muted"></i> Geen' ?>
</td>
</tr>
</table>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>

View File

@@ -0,0 +1,93 @@
<h2 class="mb-4"><i class="bi bi-people"></i> Gebruikers</h2>
<div class="row g-4">
<!-- Users list -->
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-list"></i> Huidige gebruikers</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Gebruikersnaam</th>
<th>Rol</th>
<th>Aangemaakt</th>
<th style="width: 160px;">Acties</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr>
<td>
<i class="bi bi-person-circle"></i>
<?= htmlspecialchars($u['username']) ?>
<?php if ($u['username'] === $user['username']): ?>
<span class="badge bg-info">Jij</span>
<?php endif; ?>
</td>
<td><span class="badge bg-primary"><?= htmlspecialchars($u['role']) ?></span></td>
<td class="text-muted"><?= htmlspecialchars($u['created']) ?></td>
<td>
<!-- Change password -->
<form method="POST" action="admin.php?route=users" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="change_password">
<input type="hidden" name="pw_username" value="<?= htmlspecialchars($u['username']) ?>">
<div class="input-group input-group-sm d-inline-flex" style="width: auto;">
<input type="password" name="new_password" placeholder="Nieuw ww" class="form-control form-control-sm" style="width: 100px;" required minlength="8">
<button type="submit" class="btn btn-sm btn-outline-warning" title="Wachtwoord wijzigen">
<i class="bi bi-key"></i>
</button>
</div>
</form>
<?php if ($u['username'] !== $user['username']): ?>
<form method="POST" action="admin.php?route=users" class="d-inline ms-1" onsubmit="return confirm('Weet je zeker dat je deze gebruiker wilt verwijderen?')">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="delete_username" value="<?= htmlspecialchars($u['username']) ?>">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
<i class="bi bi-trash"></i>
</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Add user form -->
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-person-plus"></i> Gebruiker toevoegen</div>
<div class="card-body">
<form method="POST" action="admin.php?route=users">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="add">
<div class="mb-3">
<label for="username" class="form-label">Gebruikersnaam</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Wachtwoord</label>
<input type="password" class="form-control" id="password" name="password" required minlength="8">
<small class="form-text text-muted">Minimaal 8 tekens.</small>
</div>
<div class="mb-3">
<label for="role" class="form-label">Rol</label>
<select class="form-select" id="role" name="role">
<option value="admin">Admin</option>
<option value="editor">Editor</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Toevoegen
</button>
</form>
</div>
</div>
</div>
</div>

View File

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

View File

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

449
public/admin.php Normal file
View File

@@ -0,0 +1,449 @@
<?php
/**
* CodePress Admin Console - Entry Point
* Access via: /admin.php?route=login|dashboard|content|config|plugins|users|logout
*/
// Security headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header_remove('X-Powered-By');
// Load admin components
$appConfig = require __DIR__ . '/../admin-console/config/app.php';
require_once __DIR__ . '/../admin-console/src/AdminAuth.php';
$auth = new AdminAuth($appConfig);
// Routing
$route = $_GET['route'] ?? '';
// Public routes (no auth required)
if ($route === 'login') {
handleLogin($auth);
exit;
}
// All other routes require authentication
if (!$auth->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;
}