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:
parent
e3a3cc5b6d
commit
60276cdccd
55
TODO.md
Normal file
55
TODO.md
Normal 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`
|
||||||
@ -3,12 +3,12 @@
|
|||||||
return [
|
return [
|
||||||
'name' => 'CodePress Admin Console',
|
'name' => 'CodePress Admin Console',
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'debug' => true,
|
'debug' => $_ENV['APP_DEBUG'] ?? false,
|
||||||
'timezone' => 'Europe/Amsterdam',
|
'timezone' => 'Europe/Amsterdam',
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
'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
|
'jwt_expiration' => 3600, // 1 hour
|
||||||
'session_timeout' => 1800, // 30 minutes
|
'session_timeout' => 1800, // 30 minutes
|
||||||
'max_login_attempts' => 5,
|
'max_login_attempts' => 5,
|
||||||
|
|||||||
@ -45,9 +45,7 @@ class CodePressCMS {
|
|||||||
$this->currentLanguage = $this->getCurrentLanguage();
|
$this->currentLanguage = $this->getCurrentLanguage();
|
||||||
$this->translations = $this->loadTranslations($this->currentLanguage);
|
$this->translations = $this->loadTranslations($this->currentLanguage);
|
||||||
|
|
||||||
// Initialize plugin manager
|
// Initialize plugin manager (files already loaded in engine/core/index.php)
|
||||||
require_once __DIR__ . '/../plugin/PluginManager.php';
|
|
||||||
require_once __DIR__ . '/../plugin/CMSAPI.php';
|
|
||||||
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins');
|
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins');
|
||||||
$api = new CMSAPI($this);
|
$api = new CMSAPI($this);
|
||||||
$this->pluginManager->setAPI($api);
|
$this->pluginManager->setAPI($api);
|
||||||
@ -187,10 +185,10 @@ class CodePressCMS {
|
|||||||
if ($item[0] === '.') continue;
|
if ($item[0] === '.') continue;
|
||||||
|
|
||||||
// Skip language-specific content that doesn't match current language
|
// Skip language-specific content that doesn't match current language
|
||||||
if (preg_match('/^(nl|en)\./', $item)) {
|
$availableLangs = array_keys($this->getAvailableLanguages());
|
||||||
$langPrefix = substr($item, 0, 2);
|
$langPattern = '/^(' . implode('|', $availableLangs) . ')\./';
|
||||||
if (($langPrefix === 'nl' && $this->currentLanguage !== 'nl') ||
|
if (preg_match($langPattern, $item, $langMatch)) {
|
||||||
($langPrefix === 'en' && $this->currentLanguage !== 'en')) {
|
if ($langMatch[1] !== $this->currentLanguage) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -261,7 +259,7 @@ class CodePressCMS {
|
|||||||
$this->searchResults[] = [
|
$this->searchResults[] = [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'path' => $relativePath,
|
'path' => $relativePath,
|
||||||
'url' => '?page=' . $relativePath,
|
'url' => '?page=' . $relativePath . '&lang=' . $this->currentLanguage,
|
||||||
'snippet' => $this->createSnippet($content, $query)
|
'snippet' => $this->createSnippet($content, $query)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -307,10 +305,6 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$page = $_GET['page'] ?? $this->config['default_page'];
|
$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
|
// Limit length
|
||||||
$page = substr($page, 0, 255);
|
$page = substr($page, 0, 255);
|
||||||
// Only remove file extension at the end, not all dots
|
// Only remove file extension at the end, not all dots
|
||||||
@ -318,6 +312,13 @@ class CodePressCMS {
|
|||||||
|
|
||||||
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
|
$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)
|
// Check if directory exists FIRST (directories take precedence over files)
|
||||||
if (is_dir($filePath)) {
|
if (is_dir($filePath)) {
|
||||||
return $this->getDirectoryListing($pageWithoutExt, $filePath);
|
return $this->getDirectoryListing($pageWithoutExt, $filePath);
|
||||||
@ -325,13 +326,6 @@ class CodePressCMS {
|
|||||||
|
|
||||||
$actualFilePath = null;
|
$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
|
// Check for exact file matches if no directory found
|
||||||
if (file_exists($filePath . '.md')) {
|
if (file_exists($filePath . '.md')) {
|
||||||
$actualFilePath = $filePath . '.md';
|
$actualFilePath = $filePath . '.md';
|
||||||
@ -509,10 +503,7 @@ class CodePressCMS {
|
|||||||
$title = trim($matches[1]);
|
$title = trim($matches[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include autoloader
|
// Configure CommonMark environment (autoloader already loaded in bootstrap)
|
||||||
require_once __DIR__ . '/../../../vendor/autoload.php';
|
|
||||||
|
|
||||||
// Configure CommonMark environment
|
|
||||||
$config = [
|
$config = [
|
||||||
'html_input' => 'strip',
|
'html_input' => 'strip',
|
||||||
'allow_unsafe_links' => false,
|
'allow_unsafe_links' => false,
|
||||||
@ -584,7 +575,7 @@ class CodePressCMS {
|
|||||||
return $text; // Don't link existing links, current page title, or H1 headings
|
return $text; // 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);
|
$content = preg_replace_callback($pattern, $replacement, $content);
|
||||||
@ -604,11 +595,6 @@ class CodePressCMS {
|
|||||||
return $pages;
|
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
|
* 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
|
* Format display name from filename
|
||||||
*
|
*
|
||||||
@ -685,39 +640,33 @@ class CodePressCMS {
|
|||||||
* @return string Formatted display name
|
* @return string Formatted display name
|
||||||
*/
|
*/
|
||||||
private function formatDisplayName($filename) {
|
private function formatDisplayName($filename) {
|
||||||
|
// Remove language prefixes dynamically based on available languages
|
||||||
|
$availableLangs = array_keys($this->getAvailableLanguages());
|
||||||
// Remove language prefixes (nl. or en.) from display names
|
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
|
||||||
if (preg_match('/^(nl|en)\.(.+)$/', $filename, $matches)) {
|
if (preg_match($langPattern, $filename, $matches)) {
|
||||||
$filename = $matches[2];
|
$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
|
// Remove file extensions (.md, .php, .html) from display names
|
||||||
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
|
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
|
||||||
|
|
||||||
// Handle special cases first (only for exact filenames, not directories)
|
// Handle special cases (case-sensitive display names)
|
||||||
// These should only apply to actual files, not directory names
|
$specialCases = [
|
||||||
if (strtolower($filename) === 'phpinfo' && !preg_match('/\//', $filename)) {
|
'phpinfo' => 'phpinfo',
|
||||||
return 'phpinfo';
|
'ict' => 'ICT',
|
||||||
}
|
];
|
||||||
if (strtolower($filename) === 'ict' && !preg_match('/\//', $filename)) {
|
if (isset($specialCases[strtolower($filename)])) {
|
||||||
return 'ICT';
|
return $specialCases[strtolower($filename)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace hyphens and underscores with spaces
|
// Replace hyphens and underscores with spaces, then title case
|
||||||
$name = str_replace(['-', '_'], ' ', $filename);
|
$name = str_replace(['-', '_'], ' ', $filename);
|
||||||
|
|
||||||
// Convert to title case (first letter uppercase, rest lowercase)
|
|
||||||
$name = ucwords(strtolower($name));
|
$name = ucwords(strtolower($name));
|
||||||
|
|
||||||
// Handle other special cases
|
// Post-process special cases in compound names
|
||||||
$name = str_replace('Phpinfo', 'phpinfo', $name);
|
foreach ($specialCases as $lower => $correct) {
|
||||||
$name = str_replace('Ict', 'ICT', $name);
|
$name = str_ireplace(ucfirst($lower), $correct, $name);
|
||||||
|
}
|
||||||
|
|
||||||
return $name;
|
return $name;
|
||||||
}
|
}
|
||||||
@ -866,10 +815,7 @@ private function getGuidePage() {
|
|||||||
$metadata = $parsed['metadata'];
|
$metadata = $parsed['metadata'];
|
||||||
$contentWithoutMeta = $parsed['content'];
|
$contentWithoutMeta = $parsed['content'];
|
||||||
|
|
||||||
// Include autoloader
|
// Configure CommonMark environment (autoloader already loaded in bootstrap)
|
||||||
require_once __DIR__ . '/../../../vendor/autoload.php';
|
|
||||||
|
|
||||||
// Configure CommonMark environment
|
|
||||||
$config = [
|
$config = [
|
||||||
'html_input' => 'strip',
|
'html_input' => 'strip',
|
||||||
'allow_unsafe_links' => false,
|
'allow_unsafe_links' => false,
|
||||||
@ -993,8 +939,6 @@ private function getGuidePage() {
|
|||||||
$hasContent = true;
|
$hasContent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$content .= '</div>';
|
|
||||||
|
|
||||||
if (!$hasContent) {
|
if (!$hasContent) {
|
||||||
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
|
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
|
||||||
}
|
}
|
||||||
@ -1168,7 +1112,7 @@ private function getGuidePage() {
|
|||||||
*/
|
*/
|
||||||
public function generateBreadcrumb() {
|
public function generateBreadcrumb() {
|
||||||
// Sidebar toggle button (shown before home icon in breadcrumb)
|
// 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'])) {
|
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>';
|
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) {
|
foreach ($parts as $i => $part) {
|
||||||
$currentPath .= ($currentPath ? '/' : '') . $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) {
|
if ($i === count($parts) - 1) {
|
||||||
// Last part - active page
|
// Last part - active page
|
||||||
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>';
|
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>';
|
||||||
} else {
|
} else {
|
||||||
// Parent directory - clickable link with separator
|
// 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) {
|
private function getContentType($page) {
|
||||||
// Try to determine content type from page request
|
// Try to determine content type from page request
|
||||||
$pagePath = $_GET['page'] ?? $this->config['default_page'];
|
$pagePath = $_GET['page'] ?? $this->config['default_page'];
|
||||||
$pagePath = htmlspecialchars($pagePath, ENT_QUOTES, 'UTF-8');
|
|
||||||
$pagePath = preg_replace('/\.[^.]+$/', '', $pagePath);
|
$pagePath = preg_replace('/\.[^.]+$/', '', $pagePath);
|
||||||
|
|
||||||
$filePath = $this->config['content_dir'] . '/' . $pagePath;
|
$filePath = $this->config['content_dir'] . '/' . $pagePath;
|
||||||
|
|||||||
@ -167,6 +167,13 @@ class CMSAPI
|
|||||||
return '';
|
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();
|
ob_start();
|
||||||
include $filePath;
|
include $filePath;
|
||||||
return ob_get_clean();
|
return ob_get_clean();
|
||||||
|
|||||||
@ -35,5 +35,6 @@ return [
|
|||||||
'plugin_development' => 'Plugin Development',
|
'plugin_development' => 'Plugin Development',
|
||||||
'template_system' => 'Template System',
|
'template_system' => 'Template System',
|
||||||
'mqtt_tracking' => 'MQTT Tracking',
|
'mqtt_tracking' => 'MQTT Tracking',
|
||||||
'real_time_analytics' => 'Real-time Analytics'
|
'real_time_analytics' => 'Real-time Analytics',
|
||||||
|
'go_to' => 'Go to'
|
||||||
];
|
];
|
||||||
@ -35,5 +35,6 @@ return [
|
|||||||
'plugin_development' => 'Plugin Ontwikkeling',
|
'plugin_development' => 'Plugin Ontwikkeling',
|
||||||
'template_system' => 'Template Systeem',
|
'template_system' => 'Template Systeem',
|
||||||
'mqtt_tracking' => 'MQTT Tracking',
|
'mqtt_tracking' => 'MQTT Tracking',
|
||||||
'real_time_analytics' => 'Real-time Analytics'
|
'real_time_analytics' => 'Real-time Analytics',
|
||||||
|
'go_to' => 'Ga naar'
|
||||||
];
|
];
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{{current_lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|||||||
@ -12,8 +12,9 @@ CodePress is a lightweight, file-based Content Management System built with PHP
|
|||||||
- Home button with icon
|
- Home button with icon
|
||||||
- Automatic menu generation
|
- Automatic menu generation
|
||||||
- Responsive design
|
- Responsive design
|
||||||
- Breadcrumb navigation
|
- Breadcrumb navigation with sidebar toggle
|
||||||
- Active state marking
|
- 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
|
### 📄 Content Types
|
||||||
- **Markdown (.md)** - CommonMark support
|
- **Markdown (.md)** - CommonMark support
|
||||||
@ -47,7 +48,7 @@ CodePress is a lightweight, file-based Content Management System built with PHP
|
|||||||
- Mustache templates
|
- Mustache templates
|
||||||
- Semantic HTML5 structure
|
- Semantic HTML5 structure
|
||||||
- **Dynamic layouts** with YAML frontmatter
|
- **Dynamic layouts** with YAML frontmatter
|
||||||
- **Sidebar support** with plugin integration
|
- **Sidebar support** with plugin integration and toggle function via breadcrumb
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,9 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
|
|||||||
- Home knop met icoon
|
- Home knop met icoon
|
||||||
- Automatische menu generatie
|
- Automatische menu generatie
|
||||||
- Responsive design
|
- Responsive design
|
||||||
- Breadcrumb navigatie
|
- Breadcrumb navigatie met sidebar toggle
|
||||||
- Active state marking
|
- 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
|
### 📄 Content Types
|
||||||
- **Markdown (.md)** - CommonMark ondersteuning
|
- **Markdown (.md)** - CommonMark ondersteuning
|
||||||
@ -47,7 +48,7 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
|
|||||||
- Mustache templates
|
- Mustache templates
|
||||||
- Semantic HTML5 structuur
|
- Semantic HTML5 structuur
|
||||||
- **Dynamic layouts** met YAML frontmatter
|
- **Dynamic layouts** met YAML frontmatter
|
||||||
- **Sidebar support** met plugin integratie
|
- **Sidebar support** met plugin integratie en toggle functie via breadcrumb
|
||||||
|
|
||||||
## Installatie
|
## Installatie
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,13 @@ class MQTTTracker
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sessionId = uniqid('cms_', true);
|
$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;
|
return $sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,8 +118,20 @@ class MQTTTracker
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Update tracking cookies
|
// Update tracking cookies
|
||||||
setcookie('cms_previous_page', $pageUrl, time() + $this->config['session_timeout'], '/');
|
setcookie('cms_previous_page', $pageUrl, [
|
||||||
setcookie('cms_page_timestamp', time(), time() + $this->config['session_timeout'], '/');
|
'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);
|
$this->publishMessage('page_visit', $pageData);
|
||||||
}
|
}
|
||||||
@ -128,7 +146,7 @@ class MQTTTracker
|
|||||||
|
|
||||||
private function trackUserFlow(): void
|
private function trackUserFlow(): void
|
||||||
{
|
{
|
||||||
if (!$this->config['track_user_flows'] ?? true) {
|
if (!($this->config['track_user_flows'] ?? true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,22 +228,30 @@ class MQTTTracker
|
|||||||
|
|
||||||
private function getClientIp(): string
|
private function getClientIp(): string
|
||||||
{
|
{
|
||||||
// Check Cloudflare header first if present
|
// Only trust REMOTE_ADDR by default - proxy headers can be spoofed
|
||||||
if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
// Configure trusted_proxies in config to enable proxy header support
|
||||||
return $_SERVER['HTTP_CF_CONNECTING_IP'];
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
}
|
$trustedProxies = $this->config['trusted_proxies'] ?? [];
|
||||||
|
|
||||||
$ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];
|
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'];
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($ipKeys as $key) {
|
$ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||||
if (!empty($_SERVER[$key])) {
|
foreach ($ipKeys as $key) {
|
||||||
$ips = explode(',', $_SERVER[$key]);
|
if (!empty($_SERVER[$key])) {
|
||||||
// Return the first IP in the list (client IP)
|
$ips = explode(',', $_SERVER[$key]);
|
||||||
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
|
private function publishMessage(string $topic, array $data): void
|
||||||
|
|||||||
@ -7,13 +7,14 @@
|
|||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const sidebar = document.getElementById('site-sidebar');
|
const sidebar = document.getElementById('site-sidebar');
|
||||||
const contentCol = sidebar ? sidebar.nextElementSibling || sidebar.parentElement.querySelector('.content-column') : null;
|
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;
|
if (!sidebar) return;
|
||||||
|
|
||||||
sidebar.classList.toggle('sidebar-hidden');
|
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 (sidebar.classList.contains('sidebar-hidden')) {
|
||||||
if (contentCol) {
|
if (contentCol) {
|
||||||
contentCol.classList.remove('col-lg-9', 'col-md-8');
|
contentCol.classList.remove('col-lg-9', 'col-md-8');
|
||||||
@ -23,6 +24,9 @@ function toggleSidebar() {
|
|||||||
icon.classList.remove('bi-layout-sidebar-inset');
|
icon.classList.remove('bi-layout-sidebar-inset');
|
||||||
icon.classList.add('bi-layout-sidebar');
|
icon.classList.add('bi-layout-sidebar');
|
||||||
}
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
sessionStorage.setItem('sidebarHidden', 'true');
|
sessionStorage.setItem('sidebarHidden', 'true');
|
||||||
} else {
|
} else {
|
||||||
if (contentCol) {
|
if (contentCol) {
|
||||||
@ -33,6 +37,9 @@ function toggleSidebar() {
|
|||||||
icon.classList.remove('bi-layout-sidebar');
|
icon.classList.remove('bi-layout-sidebar');
|
||||||
icon.classList.add('bi-layout-sidebar-inset');
|
icon.classList.add('bi-layout-sidebar-inset');
|
||||||
}
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
sessionStorage.setItem('sidebarHidden', 'false');
|
sessionStorage.setItem('sidebarHidden', 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,42 +58,37 @@ function restoreSidebarState() {
|
|||||||
|
|
||||||
// Initialize application when DOM is ready
|
// Initialize application when DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
console.log('CodePress CMS initialized');
|
|
||||||
|
|
||||||
// Restore sidebar state
|
// Restore sidebar state
|
||||||
restoreSidebarState();
|
restoreSidebarState();
|
||||||
|
|
||||||
// Handle nested dropdowns for touch devices
|
// Handle nested dropdowns for touch devices using event delegation
|
||||||
const dropdownSubmenus = document.querySelectorAll('.dropdown-submenu');
|
document.addEventListener('click', function(e) {
|
||||||
|
const toggle = e.target.closest('.dropdown-submenu .dropdown-toggle');
|
||||||
|
|
||||||
dropdownSubmenus.forEach(function(submenu) {
|
if (toggle) {
|
||||||
const toggle = submenu.querySelector('.dropdown-toggle');
|
e.preventDefault();
|
||||||
const dropdown = submenu.querySelector('.dropdown-menu');
|
e.stopPropagation();
|
||||||
|
|
||||||
if (toggle && dropdown) {
|
const submenu = toggle.closest('.dropdown-submenu');
|
||||||
// Prevent default link behavior
|
const dropdown = submenu.querySelector('.dropdown-menu');
|
||||||
toggle.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Close other submenus at the same level
|
// Close other submenus at the same level
|
||||||
const parent = submenu.parentElement;
|
const parent = submenu.parentElement;
|
||||||
parent.querySelectorAll('.dropdown-submenu').forEach(function(sibling) {
|
parent.querySelectorAll('.dropdown-submenu').forEach(function(sibling) {
|
||||||
if (sibling !== submenu) {
|
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close submenu when clicking outside
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (!submenu.contains(e.target)) {
|
|
||||||
dropdown.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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user