Merge development into main - Admin console, security fixes, sidebar toggle
# Conflicts: # engine/templates/layout.mustache # public/assets/js/app.js
This commit is contained in:
449
public/admin.php
Normal file
449
public/admin.php
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,94 @@
|
||||
// Basic CodePress CMS JavaScript
|
||||
// Main application JavaScript
|
||||
// This file contains general application functionality
|
||||
|
||||
/**
|
||||
* Toggle sidebar visibility (open/close)
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('site-sidebar');
|
||||
const contentCol = sidebar ? sidebar.nextElementSibling || sidebar.parentElement.querySelector('.content-column') : null;
|
||||
const btn = document.querySelector('.sidebar-toggle-btn');
|
||||
const icon = btn ? btn.querySelector('i') : null;
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
sidebar.classList.toggle('sidebar-hidden');
|
||||
|
||||
// Adjust content column width, toggle icon, and update aria-expanded
|
||||
if (sidebar.classList.contains('sidebar-hidden')) {
|
||||
if (contentCol) {
|
||||
contentCol.classList.remove('col-lg-9', 'col-md-8');
|
||||
contentCol.classList.add('col-12');
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-layout-sidebar-inset');
|
||||
icon.classList.add('bi-layout-sidebar');
|
||||
}
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
sessionStorage.setItem('sidebarHidden', 'true');
|
||||
} else {
|
||||
if (contentCol) {
|
||||
contentCol.classList.remove('col-12');
|
||||
contentCol.classList.add('col-lg-9', 'col-md-8');
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-layout-sidebar');
|
||||
icon.classList.add('bi-layout-sidebar-inset');
|
||||
}
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
sessionStorage.setItem('sidebarHidden', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore sidebar state from sessionStorage on page load
|
||||
*/
|
||||
function restoreSidebarState() {
|
||||
if (sessionStorage.getItem('sidebarHidden') === 'true') {
|
||||
const sidebar = document.getElementById('site-sidebar');
|
||||
if (sidebar) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize application when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('CodePress CMS loaded');
|
||||
// Restore sidebar state
|
||||
restoreSidebarState();
|
||||
|
||||
// Handle nested dropdowns for touch devices using event delegation
|
||||
document.addEventListener('click', function(e) {
|
||||
const toggle = e.target.closest('.dropdown-submenu .dropdown-toggle');
|
||||
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const submenu = toggle.closest('.dropdown-submenu');
|
||||
const dropdown = submenu.querySelector('.dropdown-menu');
|
||||
|
||||
// Close other submenus at the same level
|
||||
const parent = submenu.parentElement;
|
||||
parent.querySelectorAll('.dropdown-submenu').forEach(function(sibling) {
|
||||
if (sibling !== submenu) {
|
||||
var siblingMenu = sibling.querySelector('.dropdown-menu');
|
||||
if (siblingMenu) siblingMenu.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current submenu
|
||||
if (dropdown) dropdown.classList.toggle('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close all open submenus when clicking outside
|
||||
document.querySelectorAll('.dropdown-submenu .dropdown-menu.show').forEach(function(menu) {
|
||||
menu.classList.remove('show');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user