Fix security vulnerabilities, remove dead code, and improve code quality

- Fix path traversal with realpath() validation in getPage() and executePhpFile()
- Remove insecure JWT secret fallback, require JWT_SECRET env var
- Fix IP spoofing by only trusting proxy headers from configured proxies
- Add Secure/HttpOnly/SameSite flags to all cookies
- Use env var for debug mode instead of hardcoded true
- Fix operator precedence bug in MQTTTracker track_user_flows check
- Remove dead code: duplicate is_dir() block, unused scanForPageNames()
- Remove htmlspecialchars() from filesystem path operations
- Remove duplicate require_once calls and redundant autoloader includes
- Fix unclosed </div> in getDirectoryListing()
- Escape breadcrumb titles and add lang param to search result URLs
- Make language prefixes dynamic from config instead of hardcoded nl|en
- Make HTML lang attribute dynamic, add go_to translation key
- Add aria-label/aria-expanded to sidebar toggle for accessibility
- Fix event listener leak in app.js using event delegation
- Remove console.log from production code
- Update guides (NL/EN) with sidebar toggle documentation
- Add TODO.md documenting all identified improvements
This commit is contained in:
Edwin Noorlander 2026-02-16 15:05:27 +01:00
parent e3a3cc5b6d
commit 60276cdccd
11 changed files with 190 additions and 152 deletions

55
TODO.md Normal file
View File

@ -0,0 +1,55 @@
# CodePress CMS - Verbeteringen TODO
## Kritiek
- [x] **Path traversal fix** - `str_replace('../')` in `getPage()` is te omzeilen. Gebruik `realpath()` met prefix-check (`CodePressCMS.php:313`)
- [x] **JWT secret fallback** - Standaard `'your-secret-key-change-in-production'` maakt tokens forgeable (`admin-console/config/app.php:11`)
- [x] **executePhpFile() onveilig** - Open `include` wrapper zonder pad-restrictie (`CMSAPI.php:164`)
- [ ] **Plugin auto-loading** - Elke map in `plugins/` wordt blind geladen zonder allowlist of validatie (`PluginManager.php:40`)
## Hoog
- [x] **IP spoofing** - `X-Forwarded-For` header wordt blind vertrouwd in MQTTTracker (`MQTTTracker.php:211`)
- [x] **Debug hardcoded** - `'debug' => true` hardcoded in admin config (`admin-console/config/app.php:6`)
- [x] **Cookie security** - Cookies zonder `Secure`/`HttpOnly`/`SameSite` flags (`MQTTTracker.php:70`)
- [ ] **autoLinkPageTitles()** - Regex kan geneste `<a>` tags produceren (`CodePressCMS.php:587`)
- [ ] **extract($data)** - Kan lokale variabelen overschrijven in AuthController (`AuthController.php:77`)
- [ ] **MQTT wachtwoord** - Credentials in plain text JSON (`MQTTTracker.php:37`)
## Medium
- [x] **Dead code** - Dubbele `is_dir()` check, tweede blok onbereikbaar (`CodePressCMS.php:328-333`)
- [x] **htmlspecialchars() op bestandspad** - Corrumpeert bestandslookups in `getPage()` en `getContentType()` (`CodePressCMS.php:311, 1294`)
- [x] **Ongebruikte methode** - `scanForPageNames()` wordt nergens aangeroepen (`CodePressCMS.php:658-679`)
- [x] **Orphaned docblock** - Dubbel docblock zonder bijbehorende methode (`CodePressCMS.php:607-611`)
- [x] **Extra `</div>`** - Sluit een tag die nooit geopend is in `getDirectoryListing()` (`CodePressCMS.php:996`)
- [x] **Dubbele require_once** - PluginManager/CMSAPI geladen in zowel index.php als constructor (`CodePressCMS.php:49-50`)
- [x] **require_once autoload** - Autoloader opnieuw geladen in `parseMarkdown()` (`CodePressCMS.php:513`)
- [x] **Breadcrumb titels ongeescaped** - `$title` direct in HTML zonder `htmlspecialchars()` (`CodePressCMS.php:1197`)
- [x] **Zoekresultaat-URLs missen `&lang=`** - Taalparameter ontbreekt (`CodePressCMS.php:264`)
- [x] **Operator precedence bug** - `!$x ?? true` evalueert als `(!$x) ?? true` (`MQTTTracker.php:131`)
- [ ] **Taalwisselaar verliest pagina** - Wisselen van taal navigeert altijd naar homepage (`header.mustache:22`)
- [ ] **ctime is geen creatietijd op Linux** - `stat()` ctime is inode-wijzigingstijd (`CodePressCMS.php:400`)
- [ ] **getGuidePage() dupliceert markdown parsing** - Zelfde CommonMark setup als `parseMarkdown()` (`CodePressCMS.php:854`)
- [ ] **HTMLBlock ontbrekende `</div>`** - Niet-gesloten tags bij null-check (`HTMLBlock.php:68`)
- [ ] **CSRF-bescherming** - Login form zonder CSRF token (`AuthController.php:18`)
- [ ] **formatDisplayName() redundante logica** - Dubbele checks en overtollige str_replace (`CodePressCMS.php:688`)
## Laag
- [x] **Hardcoded 'Ga naar'** - Niet vertaalbaar in `autoLinkPageTitles()` (`CodePressCMS.php:587`)
- [x] **HTML lang attribuut** - `<html lang="en">` hardcoded i.p.v. dynamisch (`layout.mustache:2`)
- [x] **console.log in productie** - Debug log in app.js (`app.js:54`)
- [x] **Event listener leak** - N globale click listeners in forEach loop (`app.js:85`)
- [x] **Sidebar toggle aria** - Ontbrekende `aria-label` en `aria-expanded` (`CodePressCMS.php:1171`)
- [x] **Taalprefix hardcoded** - Alleen `nl|en` i.p.v. dynamisch uit config (`CodePressCMS.php:691, 190`)
- [ ] **Geen type hints** - Ontbrekende type declarations op properties en methoden
- [ ] **Public properties** - `$config`, `$currentLanguage`, `$searchResults` zouden private moeten zijn
- [ ] **Inline CSS** - ~250 regels statische CSS in template i.p.v. extern bestand
- [ ] **style.css is Bootstrap** - Bestandsnaam is misleidend, Bootstrap wordt mogelijk dubbel geladen
- [ ] **Geen error handling op file_get_contents()** - Meerdere calls zonder return-check
- [ ] **Logger slikt fouten** - `@file_put_contents()` met error suppression
- [ ] **Logger tail() leest heel bestand** - Geheugenprobleem bij grote logbestanden
- [ ] **Externe links missen rel="noreferrer"**
- [ ] **Zoekformulier mist aria-label**
- [ ] **mobile.css override Bootstrap utilities** met `!important`

View File

@ -3,12 +3,12 @@
return [
'name' => 'CodePress Admin Console',
'version' => '1.0.0',
'debug' => true,
'debug' => $_ENV['APP_DEBUG'] ?? false,
'timezone' => 'Europe/Amsterdam',
// Security
'security' => [
'jwt_secret' => $_ENV['JWT_SECRET'] ?? 'your-secret-key-change-in-production',
'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,

View File

@ -45,9 +45,7 @@ class CodePressCMS {
$this->currentLanguage = $this->getCurrentLanguage();
$this->translations = $this->loadTranslations($this->currentLanguage);
// Initialize plugin manager
require_once __DIR__ . '/../plugin/PluginManager.php';
require_once __DIR__ . '/../plugin/CMSAPI.php';
// Initialize plugin manager (files already loaded in engine/core/index.php)
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins');
$api = new CMSAPI($this);
$this->pluginManager->setAPI($api);
@ -187,10 +185,10 @@ class CodePressCMS {
if ($item[0] === '.') continue;
// Skip language-specific content that doesn't match current language
if (preg_match('/^(nl|en)\./', $item)) {
$langPrefix = substr($item, 0, 2);
if (($langPrefix === 'nl' && $this->currentLanguage !== 'nl') ||
($langPrefix === 'en' && $this->currentLanguage !== 'en')) {
$availableLangs = array_keys($this->getAvailableLanguages());
$langPattern = '/^(' . implode('|', $availableLangs) . ')\./';
if (preg_match($langPattern, $item, $langMatch)) {
if ($langMatch[1] !== $this->currentLanguage) {
continue;
}
}
@ -261,7 +259,7 @@ class CodePressCMS {
$this->searchResults[] = [
'title' => $title,
'path' => $relativePath,
'url' => '?page=' . $relativePath,
'url' => '?page=' . $relativePath . '&lang=' . $this->currentLanguage,
'snippet' => $this->createSnippet($content, $query)
];
}
@ -307,10 +305,6 @@ class CodePressCMS {
}
$page = $_GET['page'] ?? $this->config['default_page'];
// Sanitize page parameter to prevent XSS
$page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
// Prevent path traversal
$page = str_replace(['../', '..\\', '..'], '', $page);
// Limit length
$page = substr($page, 0, 255);
// Only remove file extension at the end, not all dots
@ -318,6 +312,13 @@ class CodePressCMS {
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
// Prevent path traversal using realpath validation
$realContentDir = realpath($this->config['content_dir']);
$realFilePath = realpath($filePath);
if ($realFilePath && $realContentDir && strpos($realFilePath, $realContentDir) !== 0) {
return $this->getError404();
}
// Check if directory exists FIRST (directories take precedence over files)
if (is_dir($filePath)) {
return $this->getDirectoryListing($pageWithoutExt, $filePath);
@ -325,13 +326,6 @@ class CodePressCMS {
$actualFilePath = null;
// Check if directory exists first (directories take precedence over files)
if (is_dir($filePath)) {
$directoryResult = $this->getDirectoryListing($pageWithoutExt, $filePath);
return $directoryResult;
}
// Check for exact file matches if no directory found
if (file_exists($filePath . '.md')) {
$actualFilePath = $filePath . '.md';
@ -509,10 +503,7 @@ class CodePressCMS {
$title = trim($matches[1]);
}
// Include autoloader
require_once __DIR__ . '/../../../vendor/autoload.php';
// Configure CommonMark environment
// Configure CommonMark environment (autoloader already loaded in bootstrap)
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
@ -584,7 +575,7 @@ class CodePressCMS {
return $text; // Don't link existing links, current page title, or H1 headings
}
return '<a href="?page=' . $pagePath . '&lang=' . $this->currentLanguage . '" class="auto-link" title="Ga naar ' . htmlspecialchars($pageTitle) . '">' . $text . '</a>';
return '<a href="?page=' . $pagePath . '&lang=' . $this->currentLanguage . '" class="auto-link" title="' . $this->t('go_to') . ' ' . htmlspecialchars($pageTitle) . '">' . $text . '</a>';
};
$content = preg_replace_callback($pattern, $replacement, $content);
@ -604,11 +595,6 @@ class CodePressCMS {
return $pages;
}
/**
* Get all page names from content directory (for navigation)
*
* @return array Associative array of page paths to display names
*/
/**
* Recursively scan for page titles in directory
*
@ -647,37 +633,6 @@ class CodePressCMS {
}
}
/**
* Recursively scan for page names in directory (for navigation)
*
* @param string $dir Directory to scan
* @param string $prefix Relative path prefix
* @param array &$pages Reference to pages array to populate
* @return void
*/
private function scanForPageNames($dir, $prefix, &$pages) {
if (!is_dir($dir)) return;
$items = scandir($dir);
sort($items);
foreach ($items as $item) {
if ($item[0] === '.') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) {
$this->scanForPageNames($path, $relativePath, $pages);
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
// Use filename without extension as display name
$displayName = preg_replace('/\.[^.]+$/', '', $item);
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $this->formatDisplayName($displayName);
}
}
}
/**
* Format display name from filename
*
@ -685,39 +640,33 @@ class CodePressCMS {
* @return string Formatted display name
*/
private function formatDisplayName($filename) {
// Remove language prefixes (nl. or en.) from display names
if (preg_match('/^(nl|en)\.(.+)$/', $filename, $matches)) {
// Remove language prefixes dynamically based on available languages
$availableLangs = array_keys($this->getAvailableLanguages());
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
if (preg_match($langPattern, $filename, $matches)) {
$filename = $matches[2];
}
// Remove language prefixes from directory names (nl.php-testen -> php-testen)
if (preg_match('/^(nl|en)\.php-(.+)$/', $filename, $matches)) {
$filename = 'php-' . $matches[2];
}
// Remove file extensions (.md, .php, .html) from display names
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
// Handle special cases first (only for exact filenames, not directories)
// These should only apply to actual files, not directory names
if (strtolower($filename) === 'phpinfo' && !preg_match('/\//', $filename)) {
return 'phpinfo';
}
if (strtolower($filename) === 'ict' && !preg_match('/\//', $filename)) {
return 'ICT';
// Handle special cases (case-sensitive display names)
$specialCases = [
'phpinfo' => 'phpinfo',
'ict' => 'ICT',
];
if (isset($specialCases[strtolower($filename)])) {
return $specialCases[strtolower($filename)];
}
// Replace hyphens and underscores with spaces
// Replace hyphens and underscores with spaces, then title case
$name = str_replace(['-', '_'], ' ', $filename);
// Convert to title case (first letter uppercase, rest lowercase)
$name = ucwords(strtolower($name));
// Handle other special cases
$name = str_replace('Phpinfo', 'phpinfo', $name);
$name = str_replace('Ict', 'ICT', $name);
// Post-process special cases in compound names
foreach ($specialCases as $lower => $correct) {
$name = str_ireplace(ucfirst($lower), $correct, $name);
}
return $name;
}
@ -866,10 +815,7 @@ private function getGuidePage() {
$metadata = $parsed['metadata'];
$contentWithoutMeta = $parsed['content'];
// Include autoloader
require_once __DIR__ . '/../../../vendor/autoload.php';
// Configure CommonMark environment
// Configure CommonMark environment (autoloader already loaded in bootstrap)
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
@ -993,8 +939,6 @@ private function getGuidePage() {
$hasContent = true;
}
$content .= '</div>';
if (!$hasContent) {
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
}
@ -1168,7 +1112,7 @@ private function getGuidePage() {
*/
public function generateBreadcrumb() {
// Sidebar toggle button (shown before home icon in breadcrumb)
$sidebarToggle = '<li class="breadcrumb-item sidebar-toggle-item"><button type="button" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle Sidebar"><i class="bi bi-layout-sidebar-inset"></i></button></li>';
$sidebarToggle = '<li class="breadcrumb-item sidebar-toggle-item"><button type="button" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle Sidebar" aria-label="Toggle Sidebar" aria-expanded="true"><i class="bi bi-layout-sidebar-inset"></i></button></li>';
if (isset($_GET['search'])) {
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item"><a href="?page=' . $this->config['default_page'] . '&lang=' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li><li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $this->t('search') . '</li></ol></nav>';
@ -1194,14 +1138,15 @@ private function getGuidePage() {
foreach ($parts as $i => $part) {
$currentPath .= ($currentPath ? '/' : '') . $part;
$title = ucfirst($part);
$title = htmlspecialchars(ucfirst($part), ENT_QUOTES, 'UTF-8');
$safePath = htmlspecialchars($currentPath, ENT_QUOTES, 'UTF-8');
if ($i === count($parts) - 1) {
// Last part - active page
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>';
} else {
// Parent directory - clickable link with separator
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="?page=' . $currentPath . '&lang=' . $this->currentLanguage . '">' . $title . '</a></li>';
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="?page=' . $safePath . '&lang=' . $this->currentLanguage . '">' . $title . '</a></li>';
}
}
@ -1291,7 +1236,6 @@ private function getGuidePage() {
private function getContentType($page) {
// Try to determine content type from page request
$pagePath = $_GET['page'] ?? $this->config['default_page'];
$pagePath = htmlspecialchars($pagePath, ENT_QUOTES, 'UTF-8');
$pagePath = preg_replace('/\.[^.]+$/', '', $pagePath);
$filePath = $this->config['content_dir'] . '/' . $pagePath;

View File

@ -167,6 +167,13 @@ class CMSAPI
return '';
}
// Validate file is within the CMS directory to prevent arbitrary file inclusion
$realPath = realpath($filePath);
$cmsRoot = realpath(__DIR__ . '/../../../');
if (!$realPath || !$cmsRoot || strpos($realPath, $cmsRoot) !== 0) {
return '';
}
ob_start();
include $filePath;
return ob_get_clean();

View File

@ -35,5 +35,6 @@ return [
'plugin_development' => 'Plugin Development',
'template_system' => 'Template System',
'mqtt_tracking' => 'MQTT Tracking',
'real_time_analytics' => 'Real-time Analytics'
'real_time_analytics' => 'Real-time Analytics',
'go_to' => 'Go to'
];

View File

@ -35,5 +35,6 @@ return [
'plugin_development' => 'Plugin Ontwikkeling',
'template_system' => 'Template Systeem',
'mqtt_tracking' => 'MQTT Tracking',
'real_time_analytics' => 'Real-time Analytics'
'real_time_analytics' => 'Real-time Analytics',
'go_to' => 'Ga naar'
];

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{current_lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@ -12,8 +12,9 @@ CodePress is a lightweight, file-based Content Management System built with PHP
- Home button with icon
- Automatic menu generation
- Responsive design
- Breadcrumb navigation
- Breadcrumb navigation with sidebar toggle
- Active state marking
- **Sidebar toggle** - Button placed left of HOME in the breadcrumb to open/close the sidebar. The icon changes between open and closed state. The choice is preserved during the session
### 📄 Content Types
- **Markdown (.md)** - CommonMark support
@ -47,7 +48,7 @@ CodePress is a lightweight, file-based Content Management System built with PHP
- Mustache templates
- Semantic HTML5 structure
- **Dynamic layouts** with YAML frontmatter
- **Sidebar support** with plugin integration
- **Sidebar support** with plugin integration and toggle function via breadcrumb
## Installation

View File

@ -12,8 +12,9 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
- Home knop met icoon
- Automatische menu generatie
- Responsive design
- Breadcrumb navigatie
- Breadcrumb navigatie met sidebar toggle
- Active state marking
- **Sidebar toggle** - Knop links van HOME in de breadcrumb om de sidebar te openen/sluiten. Het icoon wisselt tussen open en gesloten status. De keuze blijft behouden tijdens de sessie
### 📄 Content Types
- **Markdown (.md)** - CommonMark ondersteuning
@ -47,7 +48,7 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
- Mustache templates
- Semantic HTML5 structuur
- **Dynamic layouts** met YAML frontmatter
- **Sidebar support** met plugin integratie
- **Sidebar support** met plugin integratie en toggle functie via breadcrumb
## Installatie

View File

@ -67,7 +67,13 @@ class MQTTTracker
}
$sessionId = uniqid('cms_', true);
setcookie('cms_session_id', $sessionId, time() + $this->config['session_timeout'], '/');
setcookie('cms_session_id', $sessionId, [
'expires' => time() + $this->config['session_timeout'],
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
return $sessionId;
}
@ -112,8 +118,20 @@ class MQTTTracker
];
// Update tracking cookies
setcookie('cms_previous_page', $pageUrl, time() + $this->config['session_timeout'], '/');
setcookie('cms_page_timestamp', time(), time() + $this->config['session_timeout'], '/');
setcookie('cms_previous_page', $pageUrl, [
'expires' => time() + $this->config['session_timeout'],
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
setcookie('cms_page_timestamp', (string) time(), [
'expires' => time() + $this->config['session_timeout'],
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
$this->publishMessage('page_visit', $pageData);
}
@ -128,7 +146,7 @@ class MQTTTracker
private function trackUserFlow(): void
{
if (!$this->config['track_user_flows'] ?? true) {
if (!($this->config['track_user_flows'] ?? true)) {
return;
}
@ -210,22 +228,30 @@ class MQTTTracker
private function getClientIp(): string
{
// Check Cloudflare header first if present
// Only trust REMOTE_ADDR by default - proxy headers can be spoofed
// Configure trusted_proxies in config to enable proxy header support
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$trustedProxies = $this->config['trusted_proxies'] ?? [];
if (!empty($trustedProxies) && in_array($remoteAddr, $trustedProxies)) {
// Only trust proxy headers when request comes from a known proxy
if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
return $_SERVER['HTTP_CF_CONNECTING_IP'];
}
$ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];
$ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
foreach ($ipKeys as $key) {
if (!empty($_SERVER[$key])) {
$ips = explode(',', $_SERVER[$key]);
// Return the first IP in the list (client IP)
return trim($ips[0]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
return $remoteAddr;
}
private function publishMessage(string $topic, array $data): void

View File

@ -7,13 +7,14 @@
function toggleSidebar() {
const sidebar = document.getElementById('site-sidebar');
const contentCol = sidebar ? sidebar.nextElementSibling || sidebar.parentElement.querySelector('.content-column') : null;
const icon = document.querySelector('.sidebar-toggle-btn i');
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 and toggle icon
// 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');
@ -23,6 +24,9 @@ function toggleSidebar() {
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) {
@ -33,6 +37,9 @@ function toggleSidebar() {
icon.classList.remove('bi-layout-sidebar');
icon.classList.add('bi-layout-sidebar-inset');
}
if (btn) {
btn.setAttribute('aria-expanded', 'true');
}
sessionStorage.setItem('sidebarHidden', 'false');
}
}
@ -51,42 +58,37 @@ function restoreSidebarState() {
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
console.log('CodePress CMS initialized');
// Restore sidebar state
restoreSidebarState();
// Handle nested dropdowns for touch devices
const dropdownSubmenus = document.querySelectorAll('.dropdown-submenu');
// Handle nested dropdowns for touch devices using event delegation
document.addEventListener('click', function(e) {
const toggle = e.target.closest('.dropdown-submenu .dropdown-toggle');
dropdownSubmenus.forEach(function(submenu) {
const toggle = submenu.querySelector('.dropdown-toggle');
const dropdown = submenu.querySelector('.dropdown-menu');
if (toggle && dropdown) {
// Prevent default link behavior
toggle.addEventListener('click', function(e) {
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) {
sibling.querySelector('.dropdown-menu').classList.remove('show');
var siblingMenu = sibling.querySelector('.dropdown-menu');
if (siblingMenu) siblingMenu.classList.remove('show');
}
});
// Toggle current submenu
dropdown.classList.toggle('show');
});
if (dropdown) dropdown.classList.toggle('show');
return;
}
// Close submenu when clicking outside
document.addEventListener('click', function(e) {
if (!submenu.contains(e.target)) {
dropdown.classList.remove('show');
}
});
}
// Close all open submenus when clicking outside
document.querySelectorAll('.dropdown-submenu .dropdown-menu.show').forEach(function(menu) {
menu.classList.remove('show');
});
});
});