11 Commits

Author SHA1 Message Date
7728336fa3 Update TODO.md with admin features roadmap and AGENTS.md with current project state
TODO.md: Add Markdown editor, plugin enable/disable, plugin API,
file uploads, map management, activity log, and more admin features.
Remove resolved items (extract/AuthController/CSRF - replaced by new admin).
AGENTS.md: Document full project structure including admin-console,
add AI model info (claude-opus-4-6), admin routing, security practices.
2026-02-16 17:12:45 +01:00
8e18a5d87a 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
2026-02-16 17:01:02 +01:00
1cd9c8841d Replace GitHub references with own git server URLs in guides 2026-02-16 15:09:29 +01:00
60276cdccd 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
2026-02-16 15:05:27 +01:00
e3a3cc5b6d Add sidebar toggle button to breadcrumb with open/close functionality
Move sidebar toggle from sidebar panel to breadcrumb navigation, positioned
left of the HOME icon. Uses distinct icons for open (sidebar-inset) and
closed (sidebar) states. Sidebar state persists via sessionStorage. Remove
old non-functional toggle buttons from layout and HTMLBlock plugin.
2026-02-16 14:39:30 +01:00
b52d3a11be CMS 2.0 2026-01-06 10:02:25 +01:00
f685c2490a Merge development into main - Version 1.5.0 release
Merge includes:
- Version bump to 1.5.0 with comprehensive changelog
- Fixed guide template variable replacement bug
- Complete guide documentation rewrite
- Plugin system implementation (HTMLBlock, MQTTTracker)
- Enhanced bilingual support throughout the system
- Improved template system and layouts
- Enhanced security and code quality improvements

Resolves merge conflicts in README files, keeping version 1.5.0.
2025-11-26 17:02:43 +01:00
b1c85fc4d0 Bump version to 1.5.0 with comprehensive documentation and plugin system
Major features and improvements:
- Fix critical guide template variable replacement bug
- Complete guide documentation rewrite with examples
- Implement plugin system with HTMLBlock and MQTTTracker plugins
- Enhanced bilingual support (NL/EN) throughout the system
- Improved template system with better layout options
- Enhanced security headers and code quality
- Updated documentation and configuration examples

Version 1.5.0 represents a significant milestone with improved
documentation, plugin architecture, and bug fixes.
2025-11-26 17:02:07 +01:00
9c5a43c5ce Fix guide template variable replacement and enhance documentation
- Fix template variable replacement in guide pages by removing {{}} brackets
- Escape code blocks in guide markdown to prevent template processing
- Completely rewrite guide documentation with comprehensive CMS features
- Add bilingual guide support (English/Dutch) with detailed examples
- Enhance CodePressCMS core with improved guide page handling
- Update template system with better layout and footer components
- Improve language files with additional translations
- Update configuration with enhanced theme and language settings

Resolves issue where guide pages were showing replaced template variables
instead of displaying them as documentation examples.
2025-11-26 16:50:49 +01:00
4dd133321b Add bilingual README documentation (NL/EN) with v1.0.0 info
- Add README.en.md for English documentation
- Update README.md with language selector and v1.0.0 info
- Include dual-license information (AGPL v3 + Commercial)
- Add quality metrics (Security: 100/100, Code: 98/100)
- Add comprehensive feature documentation
- Add installation and configuration guides
- Add class documentation for developers
2025-11-24 17:01:27 +01:00
f5ac28a74e Add bilingual README documentation (NL/EN) with v1.0.0 info
- Add README.en.md for English documentation
- Update README.md with language selector and v1.0.0 info
- Include dual-license information (AGPL v3 + Commercial)
- Add quality metrics (Security: 100/100, Code: 98/100)
- Add comprehensive feature documentation
- Add installation and configuration guides
- Add class documentation for developers
2025-11-24 17:01:19 +01:00
144 changed files with 16484 additions and 650 deletions

101
AGENTS.md
View File

@@ -1,22 +1,97 @@
# Agent Instructions for CodePress CMS
## AI Model
- **Huidig model**: `claude-opus-4-6` (OpenCode / `opencode/claude-opus-4-6`)
- Sessie gestart: 16 feb 2026
## Build & Run
- **Run Server**: `php -S localhost:8080 -t public`
- **Lint PHP**: `find . -name "*.php" -exec php -l {} \;`
- **Dependencies**: No Composer/NPM required. Native PHP 8.4+ implementation.
- **Lint PHP**: `find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \;`
- **Dependencies**: Composer vereist voor CommonMark. Geen NPM.
- **Admin Console**: Toegankelijk op `/admin.php` (standaard login: `admin` / `admin`)
## Project Structuur
```
codepress/
├── engine/
│ ├── core/
│ │ ├── class/
│ │ │ ├── CodePressCMS.php # Hoofd CMS class
│ │ │ ├── Logger.php # Logging systeem
│ │ │ └── SimpleTemplate.php # Mustache-style template engine
│ │ ├── plugin/
│ │ │ ├── PluginManager.php # Plugin loader
│ │ │ └── CMSAPI.php # API voor plugins
│ │ ├── config.php # Config loader (leest config.json)
│ │ └── index.php # Bootstrap (autoloader, requires)
│ ├── lang/ # Taalbestanden (nl.php, en.php)
│ └── templates/ # Mustache templates
│ ├── layout.mustache # Hoofd layout (bevat inline CSS)
│ ├── assets/
│ │ ├── header.mustache
│ │ ├── navigation.mustache
│ │ └── footer.mustache
│ ├── markdown_content.mustache
│ ├── php_content.mustache
│ └── html_content.mustache
├── admin-console/ # Admin paneel
│ ├── config/
│ │ ├── app.php # Admin app configuratie
│ │ └── admin.json # Gebruikers & security (file-based)
│ ├── src/
│ │ └── AdminAuth.php # Authenticatie (sessies, bcrypt, CSRF, lockout)
│ ├── templates/
│ │ ├── login.php # Login pagina
│ │ ├── layout.php # Admin layout met sidebar
│ │ └── pages/
│ │ ├── dashboard.php
│ │ ├── content.php
│ │ ├── content-edit.php
│ │ ├── content-new.php
│ │ ├── config.php
│ │ ├── plugins.php
│ │ └── users.php
│ └── storage/logs/ # Admin logs
├── plugins/ # CMS plugins
│ ├── HTMLBlock/
│ └── MQTTTracker/
├── public/ # Web root
│ ├── assets/css/js/
│ ├── index.php # Website entry point
│ └── admin.php # Admin entry point + router
├── content/ # Content bestanden
├── guide/ # Handleidingen (nl/en)
├── config.json # Site configuratie
├── TODO.md # Openstaande verbeteringen
└── AGENTS.md # Dit bestand
```
## Code Style & Conventions
- **PHP Standards**: Follow PSR-12. Use 4 spaces for indentation.
- **Naming**: Classes `PascalCase` (e.g., `CodePressCMS`), methods `camelCase` (e.g., `renderMenu`), variables `camelCase`, config keys `snake_case`.
- **Architecture**:
- Core logic resides in `index.php`.
- Configuration in `config.php`.
- Public entry point is `public/index.php`.
- **Content**: Stored in `public/content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static).
- **Templating**: Simple string replacement `{{placeholder}}` in `templates/layout.html`.
- Core CMS logic in `engine/core/class/CodePressCMS.php`
- Bootstrap/requires in `engine/core/index.php`
- Configuration loaded from `config.json` via `engine/core/config.php`
- Public website entry point: `public/index.php`
- Admin entry point + routing: `public/admin.php`
- Admin authenticatie: `admin-console/src/AdminAuth.php`
- **Content**: Stored in `content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static).
- **Templating**: Mustache-style `{{placeholder}}` in `templates/layout.mustache` via `SimpleTemplate.php`.
- **Navigation**: Auto-generated from directory structure. Folders require an index file to be clickable in breadcrumbs.
- **Security**: Always use `htmlspecialchars()` for outputting user/content data.
- **Git**: `main` is the clean CMS core. `e.noorlander` contains personal content. Do not mix them.
- **Security**:
- Always use `htmlspecialchars()` for outputting user/content data
- Use `realpath()` + prefix-check for path traversal prevention
- Admin forms require CSRF tokens via `AdminAuth::verifyCsrf()`
- Passwords stored as bcrypt hashes in `admin.json`
- **Git**: `main` is the clean CMS core. `development` is de actieve development branch. `e.noorlander` bevat persoonlijke content. Niet mixen.
## Admin Console
- **File-based**: Geen database. Gebruikers opgeslagen in `admin-console/config/admin.json`
- **Routing**: Via `?route=` parameter in `public/admin.php`
- **Routes**: `login`, `logout`, `dashboard`, `content`, `content-edit`, `content-new`, `content-delete`, `config`, `plugins`, `users`
- **Auth**: Session-based. `AdminAuth` class handelt login, logout, CSRF, brute-force lockout af
- **Templates**: Pure PHP templates in `admin-console/templates/pages/`. Layout in `layout.php`
## Important: Title vs File/Directory Name Logic
- **CRITICAL**: When user asks for "title" corrections, they usually mean **FILE/DIRECTORY NAME WITHOUT LANGUAGE PREFIX AND EXTENSIONS**, not the HTML title from content!
@@ -26,4 +101,10 @@
- `en.php-testen` → display as "Php Testen" (not "ICT")
- **Method**: Use `formatDisplayName()` to process file/directory names correctly
- **Priority**: Directory names take precedence over file names when both exist
- **Language prefixes**: Always remove `nl.` or `en.` prefixes from display names
- **Language prefixes**: Dynamisch verwijderd op basis van beschikbare talen via `getAvailableLanguages()`
## Bekende aandachtspunten
- LSP errors over "Undefined function" in PHP files zijn vals-positief (standaard PHP functies worden niet herkend door de LSP). Negeer deze.
- Zie `TODO.md` voor alle openstaande verbeteringen en nieuwe features.
- `vendor/` map bevat Composer dependencies (CommonMark, Mustache). Niet handmatig wijzigen.
- `admin-console/config/admin.json` bevat wachtwoord-hashes. Niet committen met echte productie-wachtwoorden.

408
README.en.md Normal file
View File

@@ -0,0 +1,408 @@
# CodePress CMS
**[🇳🇱 Nederlands](README.md) | [🇬🇧 English](#)**
A lightweight, file-based content management system built with PHP.
**Version:** 1.5.0 | **License:** AGPL v3 / Commercial
## ✨ Features
- 📝 **Multi-format Content** - Supports Markdown, PHP and HTML files
- 🧭 **Dynamic Navigation** - Automatic menu generation with dropdowns
- 🔍 **Search Functionality** - Full-text search through all content
- 🧭 **Breadcrumb Navigation** - Intuitive navigation paths
- 🔗 **Auto-linking** - Automatic links between pages
- 📱 **Responsive Design** - Works perfectly on all devices
- ⚙️ **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
1. **Upload** files to web server
2. **Set permissions** for web server
3. **Configure** (optional) via `config.json`
4. **Visit** website via browser
## 📁 Project Structure
```
codepress/
├── engine/
│ ├── core/
│ │ ├── class/
│ │ │ ├── CodePressCMS.php # Main CMS class
│ │ │ ├── Logger.php # Logging system
│ │ │ └── SimpleTemplate.php # Template engine
│ │ ├── config.php # Configuration loader
│ │ └── index.php # CMS engine
│ ├── lang/ # Language files (nl.php, en.php)
│ └── templates/ # Template files
│ ├── layout.mustache
│ ├── assets/
│ │ ├── header.mustache
│ │ ├── navigation.mustache
│ │ └── footer.mustache
│ ├── 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/
│ │ ├── js/
│ │ └── favicon.svg
│ ├── content/ # Content files
│ │ ├── nl.homepage.md # Dutch homepage
│ │ ├── en.homepage.md # English homepage
│ │ └── [lang].[page].md # Multi-language pages
│ ├── index.php # Website entry point
│ └── admin.php # Admin entry point
├── config.json # Configuration
├── version.php # Version tracking
└── README.md # This file
```
## ⚙️ Configuration
### Basic Configuration (`config.json`)
```json
{
"site_title": "CodePress",
"content_dir": "public/content",
"templates_dir": "engine/templates",
"default_page": "homepage",
"default_lang": "nl",
"author": {
"name": "Edwin Noorlander",
"website": "https://noorlander.info",
"git": "https://git.noorlander.info/E.Noorlander/CodePress.git"
},
"seo": {
"description": "CodePress CMS - Lightweight file-based content management system",
"keywords": "cms, php, content management, file-based"
}
}
```
### Configuration Options
- **`site_title`** - Website name
- **`content_dir`** - Directory with content files
- **`templates_dir`** - Directory with template files
- **`default_page`** - Default page (e.g., `"homepage"`)
- **`default_lang`** - Default language (`"nl"` or `"en"`)
- **`author`** - Author information with links
- **`seo`** - SEO settings
## 📝 Content Types
### Markdown (.md)
- Auto-linking between pages
- GitHub Flavored Markdown support
- Syntax highlighting for code blocks
- Automatic title extraction
- Multi-language support with `[lang].[page].md` format
### PHP (.php)
- Full PHP support
- Dynamic content generation
- Database integration possible
- Session management available
### HTML (.html)
- Static HTML pages
- Bootstrap components
- Custom CSS and JavaScript
- Full HTML5 validation
## 🌍 Multi-language Support
CodePress supports multiple languages with automatic detection:
### File Naming Convention
- `nl.[page].md` - Dutch content
- `en.[page].md` - English content
- Language prefix is automatically removed from display
### URL Format
- `/?page=test&lang=nl` - Dutch version
- `/?page=test&lang=en` - English version
- `/?page=test` - Uses default language from config
### Language Switching
- Automatic language selector in navigation
- Preserves current page when switching languages
- Falls back to default language if translation missing
## 🎨 Design Features
### Navigation
- **Tab-style navigation** with Bootstrap
- **Dropdown menus** for folders and sub-folders
- **Home button** with icon
- **Active state** indication
- **Responsive** hamburger menu
- **Language selector** with flags
### Layout
- **Flexbox layout** for modern structure
- **Fixed header** with logo and search
- **Breadcrumb navigation** between header and content
- **Fixed footer** with metadata and version
- **Scrollable content** area
### Responsive
- **Mobile-first** approach
- **Touch-friendly** interaction
- **Adaptive** widths
- **Consistent** experience
## 🔧 Requirements
- **PHP 8.4+** or higher
- **Web server** (Apache, Nginx, etc.)
- **Write permissions** for PHP files
- **Mod_rewrite** (optional for pretty URLs)
## 🛠️ Installation
### Via Composer
```bash
composer create-project codepress/codepress
cd codepress
```
### Manual
1. **Download** the files
2. **Upload** to web server
3. **Set permissions** (755 for directories, 644 for files)
4. **Configure** `config.json`
### Web Server Configuration
#### Apache
```apache
<Directory "/var/www/codepress">
AllowOverride All
Require all granted
</Directory>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
</IfModule>
```
#### Nginx
```nginx
server {
root /var/www/codepress/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
}
}
```
## 🏗️ PHP Classes
### SimpleTemplate Class
`engine/core/class/SimpleTemplate.php`
Lightweight template rendering engine supporting Mustache-style syntax without external dependencies.
**Methods:**
- `render($template, $data)` - Renders template with data
- `replacePartial($matches)` - Replaces `{{>partial}}` placeholders
**Features:**
- `{{>partial}}` - Partial includes
- `{{#variable}}...{{/variable}}` - Conditional blocks
- `{{^variable}}...{{/variable}}` - Negative conditional blocks
- `{{{variable}}}` - Unescaped HTML content
- `{{variable}}` - Escaped content
### CodePressCMS Class
`engine/core/class/CodePressCMS.php`
Main CMS class managing all content management functionality.
**Public Methods:**
- `__construct($config)` - Initialize CMS with configuration
- `getPage()` - Retrieves current page content
- `getMenu()` - Generates navigation structure
- `render()` - Renders complete page with templates
**Private Methods:**
- `buildMenu()` - Builds menu structure from content directory
- `scanDirectory($dir, $prefix)` - Scans directory for content
- `performSearch($query)` - Executes search query
- `parseMarkdown($content)` - Converts Markdown to HTML
- `parsePHP($filePath)` - Processes PHP files
- `parseHTML($content)` - Processes HTML files
- `getBreadcrumb()` - Generates breadcrumb navigation
- `renderMenu($items, $level)` - Renders menu HTML
- `getContentType($page)` - Determines content type
- `formatDisplayName($name)` - Formats file/directory names for display
**Features:**
- Multi-format content support (MD, PHP, HTML)
- Dynamic navigation with dropdowns
- Search functionality with snippets
- Breadcrumb navigation
- Auto-linking between pages
- File metadata tracking
- Responsive template rendering
- Multi-language support
### Logger Class
`engine/core/class/Logger.php`
Structured logging system for debugging and monitoring.
**Methods:**
- `__construct($logFile, $level)` - Initialize logger
- `debug($message, $context)` - Debug level logging
- `info($message, $context)` - Info level logging
- `warning($message, $context)` - Warning level logging
- `error($message, $context)` - Error level logging
**Features:**
- PSR-3 compatible logging interface
- Configurable log levels
- JSON context support
- File-based logging with rotation
- Timestamp and severity tracking
## 🔒 Security
CodePress CMS has undergone comprehensive security testing:
- **Security Score:** 100/100
- **Penetration Tests:** 40+ tests passed
- **Vulnerabilities:** 0 critical, 0 high, 0 medium
- **Protection:** XSS, SQL Injection, Path Traversal, CSRF
- **Headers:** CSP, X-Frame-Options, X-Content-Type-Options
- **Input Validation:** All user inputs sanitized
- **Output Encoding:** htmlspecialchars() on all output
See [pentest/PENTEST.md](pentest/PENTEST.md) for detailed security report.
## 📊 Quality Metrics
### Functionality: 92/100
- ✅ 46/50 tests passed
- ✅ Core functionality working
- ⚠️ 4 minor issues (non-critical)
### Code Quality: 98/100
- ✅ Clean, maintainable code
- ✅ PSR-12 compliant
- ✅ No unused functions
- ✅ Structured logging system
### Overall: 96/100
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
- **[Guide (EN)](guide/en.codepress.md)** - English documentation
- **[AGENTS.md](AGENTS.md)** - Developer instructions
- **[DEVELOPMENT.md](DEVELOPMENT.md)** - Development guide
- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Contribution guidelines
## 🤝 Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
**Important:**
- All contributions must be notified to the author
- Contributions are subject to AGPL v3 terms
- Contact commercial@noorlander.info for commercial licensing
## 📄 License
CodePress CMS is available under a **dual-license model**:
### 🆓 AGPL v3 (Open-Source)
- **Free** for non-commercial use
- **Requires** sharing modifications
- **Copyleft** protection
- See [LICENSE](LICENSE) for details
### 💼 Commercial License
For commercial use without AGPL obligations:
- **Individual:** €99 (1 developer)
- **Business:** €499 (10 developers)
- **Enterprise:** €2499 (unlimited)
- **SaaS:** €999/year
📧 **Contact:** commercial@noorlander.info
📖 **More info:** [LICENSE-INFO.md](LICENSE-INFO.md)
## 🔗 Links
- **Website**: https://noorlander.info
- **Repository**: https://git.noorlander.info/E.Noorlander/CodePress
- **Issues**: https://git.noorlander.info/E.Noorlander/CodePress/issues
- **Releases**: https://git.noorlander.info/E.Noorlander/CodePress/releases
## 📦 Version History
See [version.php](version.php) for detailed changelog.
**Current Version: 1.0.0**
- Initial production release
- AGPL v3 + Commercial dual-license
- Multi-language support (NL/EN)
- Security score: 100/100
- Code quality: 98/100
- Comprehensive testing suite
---
*Built with ❤️ by Edwin Noorlander*

View File

@@ -1,7 +1,11 @@
# CodePress CMS
**[🇳🇱 Nederlands](#) | [🇬🇧 English](README.en.md)**
Een lichtgewicht, file-based content management systeem gebouwd met PHP.
**Versie:** 1.5.0 | **Licentie:** AGPL v3 / Commercial
## ✨ Features
- 📝 **Multi-format Content** - Ondersteunt Markdown, PHP en HTML bestanden
@@ -13,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
@@ -43,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
```
@@ -226,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
@@ -238,7 +273,24 @@ Bijdragen zijn welkom! Zie [AGENTS.md](AGENTS.md) voor ontwikkelrichtlijnen.
## 📄 Licentie
Open-source licentie. Zie de repository voor meer informatie.
CodePress CMS is beschikbaar onder een **dual-license model**:
### 🆓 AGPL v3 (Open-Source)
- **Gratis** voor niet-commercieel gebruik
- **Vereist** het delen van wijzigingen
- **Copyleft** bescherming
- Zie [LICENSE](LICENSE) voor details
### 💼 Commercial License
Voor bedrijfsmatig gebruik zonder AGPL verplichtingen:
- **Individual:** €99 (1 developer)
- **Business:** €499 (10 developers)
- **Enterprise:** €2499 (unlimited)
- **SaaS:** €999/jaar
📧 **Contact:** commercial@noorlander.info
📖 **Meer info:** [LICENSE-INFO.md](LICENSE-INFO.md)
## 🔗 Links

82
TODO.md Normal file
View File

@@ -0,0 +1,82 @@
# 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`)
- [ ] **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`)
- [ ] **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`
---
## Admin Console - Nieuwe features
### Hoog
- [ ] **Markdown editor** - WYSIWYG/split-view Markdown editor integreren in content-edit (bijv. EasyMDE, SimpleMDE, of Toast UI Editor). Live preview, toolbar met opmaakknoppen, drag & drop afbeeldingen
- [ ] **Plugin activeren/deactiveren** - Toggle knop per plugin in admin Plugins pagina. Schrijft `enabled: true/false` naar plugin `config.json`. PluginManager moet `enabled` status respecteren bij het laden
- [ ] **Plugin API** - Uitgebreide API voor plugins zodat ze kunnen inhaken op CMS events (hooks/filters). Denk aan: `onPageLoad`, `onBeforeRender`, `onAfterRender`, `onSearch`, `onMenuBuild`. Plugins moeten sidebar content, head tags, en footer scripts kunnen injecteren
### Medium
- [ ] **Plugin configuratie editor** - Per-plugin config.json bewerken vanuit admin panel
- [ ] **Bestand uploaden** - Afbeeldingen en bestanden uploaden via admin Content pagina
- [ ] **Map aanmaken/verwijderen** - Directory management in admin Content pagina
- [ ] **Admin activity log** - Logboek van alle admin acties (wie deed wat wanneer) met viewer in dashboard
- [ ] **Wachtwoord wijzigen eigen account** - Apart formulier voor ingelogde gebruiker om eigen wachtwoord te wijzigen (met huidig wachtwoord verificatie)
- [ ] **Admin thema** - Admin sidebar kleur overnemen van site thema config (`header_color`)
### Laag
- [ ] **Content preview** - Live preview van Markdown/HTML content naast de editor
- [ ] **Content versioning** - Simpele file-based backup bij elke save (bijv. `.bak` bestanden)
- [ ] **Zoeken in admin** - Zoekfunctie binnen de admin content browser
- [ ] **Drag & drop** - Bestanden herordenen/verplaatsen via drag & drop
- [ ] **Keyboard shortcuts** - Ctrl+S om op te slaan in editor, Ctrl+N voor nieuw bestand
- [ ] **Dark mode** - Admin panel dark mode toggle
- [ ] **Responsive admin** - Admin sidebar inklapbaar op mobiel (nu is het gestacked)

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

@@ -0,0 +1,17 @@
<?php
return [
'name' => 'CodePress Admin',
'version' => '1.0.0',
'debug' => $_ENV['APP_DEBUG'] ?? false,
'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

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

@@ -1,6 +1,8 @@
{
"require": {
"mustache/mustache": "^3.0",
"league/commonmark": "^2.7"
"league/commonmark": "^2.7",
"php-mqtt/client": "^2.0",
"geoip2/geoip2": "^2.13"
}
}

196
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3cf7d71c2b61afde676a52c0c83f8bfe",
"content-hash": "ca2f778e274e1087d5066837f53bcd23",
"packages": [
{
"name": "dflydev/dot-access-data",
@@ -83,16 +83,16 @@
},
{
"name": "league/commonmark",
"version": "2.7.1",
"version": "2.8.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
"reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
"shasum": ""
},
"require": {
@@ -129,7 +129,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.8-dev"
"dev-main": "2.9-dev"
}
},
"autoload": {
@@ -186,7 +186,7 @@
"type": "tidelift"
}
],
"time": "2025-07-20T12:47:49+00:00"
"time": "2025-11-26T21:48:24+00:00"
},
{
"name": "league/config",
@@ -323,6 +323,69 @@
},
"time": "2025-06-28T18:28:20+00:00"
},
{
"name": "myclabs/php-enum",
"version": "1.8.5",
"source": {
"type": "git",
"url": "https://github.com/myclabs/php-enum.git",
"reference": "e7be26966b7398204a234f8673fdad5ac6277802"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802",
"reference": "e7be26966b7398204a234f8673fdad5ac6277802",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.3 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "1.*",
"vimeo/psalm": "^4.6.2 || ^5.2"
},
"type": "library",
"autoload": {
"psr-4": {
"MyCLabs\\Enum\\": "src/"
},
"classmap": [
"stubs/Stringable.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP Enum contributors",
"homepage": "https://github.com/myclabs/php-enum/graphs/contributors"
}
],
"description": "PHP Enum implementation",
"homepage": "https://github.com/myclabs/php-enum",
"keywords": [
"enum"
],
"support": {
"issues": "https://github.com/myclabs/php-enum/issues",
"source": "https://github.com/myclabs/php-enum/tree/1.8.5"
},
"funding": [
{
"url": "https://github.com/mnapoli",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum",
"type": "tidelift"
}
],
"time": "2025-01-14T11:49:03+00:00"
},
{
"name": "nette/schema",
"version": "v1.3.3",
@@ -390,16 +453,16 @@
},
{
"name": "nette/utils",
"version": "v4.0.8",
"version": "v4.0.9",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede"
"reference": "505a30ad386daa5211f08a318e47015b501cad30"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede",
"reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede",
"url": "https://api.github.com/repos/nette/utils/zipball/505a30ad386daa5211f08a318e47015b501cad30",
"reference": "505a30ad386daa5211f08a318e47015b501cad30",
"shasum": ""
},
"require": {
@@ -473,9 +536,66 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.0.8"
"source": "https://github.com/nette/utils/tree/v4.0.9"
},
"time": "2025-08-06T21:43:34+00:00"
"time": "2025-10-31T00:45:47+00:00"
},
{
"name": "php-mqtt/client",
"version": "v2.3.0",
"source": {
"type": "git",
"url": "https://github.com/php-mqtt/client.git",
"reference": "3d141846753a0adee265680ae073cfb9030f2390"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-mqtt/client/zipball/3d141846753a0adee265680ae073cfb9030f2390",
"reference": "3d141846753a0adee265680ae073cfb9030f2390",
"shasum": ""
},
"require": {
"myclabs/php-enum": "^1.7",
"php": "^8.0",
"psr/log": "^1.1|^2.0|^3.0"
},
"require-dev": {
"phpunit/php-invoker": "^3.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "^3.5"
},
"suggest": {
"ext-redis": "Required for the RedisRepository"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpMqtt\\Client\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Marvin Mall",
"email": "marvin-mall@msn.com",
"role": "developer"
}
],
"description": "An MQTT client written in and for PHP.",
"keywords": [
"client",
"mqtt",
"publish",
"subscribe"
],
"support": {
"issues": "https://github.com/php-mqtt/client/issues",
"source": "https://github.com/php-mqtt/client/tree/v2.3.0"
},
"time": "2025-09-30T17:53:34+00:00"
},
{
"name": "psr/event-dispatcher",
@@ -527,6 +647,56 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",

View File

@@ -8,7 +8,9 @@
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff"
"navigation_font_color": "#ffffff",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6"
},
"language": {
"default": "nl",
@@ -19,9 +21,8 @@
"keywords": "cms, php, content management, file-based"
},
"author": {
"name": "Edwin Noorlander",
"website": "https://noorlander.info",
"git": "https://git.noorlander.info/E.Noorlander/CodePress.git"
"name": "E. Noorlander",
"website": "https://noorlander.info"
},
"features": {
"auto_link_pages": true,

View File

@@ -21,11 +21,12 @@
* @license MIT
*/
class CodePressCMS {
private $config;
public $config;
public $currentLanguage;
public $searchResults = [];
private $menu = [];
private $searchResults = [];
private $currentLanguage;
private $translations = [];
private $pluginManager;
/**
* Constructor - Initialize the CMS with configuration
@@ -43,6 +44,12 @@ class CodePressCMS {
$this->currentLanguage = $this->getCurrentLanguage();
$this->translations = $this->loadTranslations($this->currentLanguage);
// 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);
$this->buildMenu();
if (isset($_GET['search'])) {
@@ -67,7 +74,7 @@ class CodePressCMS {
*
* @return array Available languages with their codes and names
*/
private function getAvailableLanguages() {
public function getAvailableLanguages() {
$langDir = __DIR__ . '/../../lang/';
$languages = [];
@@ -178,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;
}
}
@@ -252,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)
];
}
@@ -298,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
@@ -309,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);
@@ -316,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';
@@ -444,23 +447,63 @@ class CodePressCMS {
];
}
/**
* Parse metadata from content
*
* @param string $content Raw content
* @return array Parsed metadata and content without meta block
*/
private function parseMetadata($content) {
$metadata = [];
$contentWithoutMeta = $content;
// Check for YAML frontmatter (--- at start and end)
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)$/s', $content, $matches)) {
$metaContent = $matches[1];
$contentWithoutMeta = $matches[2];
// Parse YAML-like metadata
$lines = explode("\n", $metaContent);
foreach ($lines as $line) {
if (strpos($line, ':') !== false) {
list($key, $value) = explode(':', $line, 2);
$key = trim($key);
$value = trim($value, ' "\'');
// Handle boolean values
if ($value === 'true') $value = true;
elseif ($value === 'false') $value = false;
$metadata[$key] = $value;
}
}
}
return [
'metadata' => $metadata,
'content' => $contentWithoutMeta
];
}
/**
* Parse Markdown content to HTML using League CommonMark
*
* @param string $content Raw Markdown content
* @return array Parsed content with title and body
*/
private function parseMarkdown($content, $actualFilePath = '') {
// Extract title from first H1
$title = '';
if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
public function parseMarkdown($content, $actualFilePath = '') {
// Parse metadata first
$parsed = $this->parseMetadata($content);
$metadata = $parsed['metadata'];
$content = $parsed['content'];
// Extract title from first H1 or metadata
$title = $metadata['title'] ?? '';
if (empty($title) && preg_match('/^#\s+(.+)$/m', $content, $matches)) {
$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,
@@ -495,8 +538,10 @@ class CodePressCMS {
$body = preg_replace('/href="\/([^"]+)"/', 'href="?page=$1"', $body);
return [
'title' => $cleanName ?: 'Untitled',
'content' => $body
'title' => $title ?: $cleanName ?: 'Untitled',
'content' => $body,
'metadata' => $metadata,
'layout' => $metadata['layout'] ?? 'sidebar-content'
];
}
@@ -530,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);
@@ -544,17 +589,12 @@ class CodePressCMS {
*
* @return array Associative array of page paths to titles
*/
private function getAllPageTitles() {
public function getAllPageTitles() {
$pages = [];
$this->scanForPageTitles($this->config['content_dir'], '', $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
*
@@ -593,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
*
@@ -631,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;
}
@@ -708,19 +711,36 @@ class CodePressCMS {
* @return array Parsed content with title and body
*/
private function parsePHP($filePath) {
// Read file content first to extract metadata
$fileContent = file_get_contents($filePath);
$parsed = $this->parseMetadata($fileContent);
$metadata = $parsed['metadata'];
// Extract title from metadata or PHP variables
$title = $metadata['title'] ?? '';
ob_start();
// Make metadata available to the included file
$pageMetadata = $metadata;
include $filePath;
$content = ob_get_clean();
// Remove any remaining metadata from PHP output
$content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content);
// Remove metadata from content if it was included
$parsed = $this->parseMetadata($content);
$content = $parsed['content'];
// Extract filename for title
$filename = basename($filePath);
$cleanName = $this->formatDisplayName($filename);
return [
'title' => $cleanName ?: 'Untitled',
'content' => $content
'title' => $title ?: $cleanName ?: 'Untitled',
'content' => $content,
'metadata' => $metadata,
'layout' => $metadata['layout'] ?? 'sidebar-content'
];
}
@@ -731,13 +751,30 @@ class CodePressCMS {
* @return array Parsed content with title and body
*/
private function parseHTML($content, $actualFilePath = '') {
// Parse metadata first
$parsed = $this->parseMetadata($content);
$metadata = $parsed['metadata'];
$content = $parsed['content'];
// Extract title from metadata or HTML tags
$title = $metadata['title'] ?? '';
if (empty($title)) {
if (preg_match('/<title>(.*?)<\/title>/i', $content, $matches)) {
$title = trim(strip_tags($matches[1]));
} elseif (preg_match('/<h1[^>]*>(.*?)<\/h1>/i', $content, $matches)) {
$title = trim(strip_tags($matches[1]));
}
}
// Extract filename for title
$filename = basename($actualFilePath);
$cleanName = $this->formatDisplayName($filename);
return [
'title' => $cleanName ?: 'Untitled',
'content' => $content
'title' => $title ?: $cleanName ?: 'Untitled',
'content' => $content,
'metadata' => $metadata,
'layout' => $metadata['layout'] ?? 'sidebar-content'
];
}
@@ -746,7 +783,7 @@ class CodePressCMS {
*
* @return bool True if content directory is empty or doesn't exist
*/
private function isContentDirEmpty() {
public function isContentDirEmpty() {
$contentDir = $this->config['content_dir'];
if (!is_dir($contentDir)) {
return true;
@@ -763,21 +800,57 @@ class CodePressCMS {
*
* @return array Guide page data
*/
private function getGuidePage() {
private function getGuidePage() {
$lang = $this->currentLanguage;
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
if (!file_exists($guideFile)) {
$guideFile = __DIR__ . '/../../../guide/en.codepress.md'; // Fallback to English
}
}
$content = file_get_contents($guideFile);
$result = $this->parseMarkdown($content);
// Parse metadata first
$parsed = $this->parseMetadata($content);
$metadata = $parsed['metadata'];
$contentWithoutMeta = $parsed['content'];
// Configure CommonMark environment (autoloader already loaded in bootstrap)
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
'max_nesting_level' => 100,
];
// Create environment with extensions
$environment = new \League\CommonMark\Environment\Environment($config);
$environment->addExtension(new \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension());
$environment->addExtension(new \League\CommonMark\Extension\Autolink\AutolinkExtension());
$environment->addExtension(new \League\CommonMark\Extension\Strikethrough\StrikethroughExtension());
$environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension());
$environment->addExtension(new \League\CommonMark\Extension\TaskList\TaskListExtension());
// Create converter
$converter = new \League\CommonMark\MarkdownConverter($environment);
// Convert to HTML
$body = $converter->convert($contentWithoutMeta)->getContent();
// Extract title from metadata or first H1
$title = $metadata['title'] ?? '';
if (empty($title) && preg_match('/^#\s+(.+)$/m', $contentWithoutMeta, $matches)) {
$title = trim($matches[1]);
}
// Set special title for guide
$result['title'] = $this->t('manual') . ' - CodePress CMS';
$title = $this->t('manual') . ' - CodePress CMS';
return $result;
return [
'title' => $title,
'content' => $body,
'metadata' => $metadata,
'layout' => $metadata['layout'] ?? 'content'
];
}
/**
@@ -866,8 +939,6 @@ class CodePressCMS {
$hasContent = true;
}
$content .= '</div>';
if (!$hasContent) {
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
}
@@ -912,12 +983,21 @@ class CodePressCMS {
// Get homepage title
$homepageTitle = $this->getHomepageTitle();
// Get sidebar content from plugins
$sidebarContent = $this->pluginManager->getSidebarContent();
// Get layout from page metadata
$layout = $page['layout'] ?? 'sidebar-content';
// Prepare template data
$templateData = [
'site_title' => $this->config['site_title'],
'page_title' => htmlspecialchars($page['title']),
'content' => $page['content'],
'sidebar_content' => $sidebarContent,
'layout' => $layout,
'page_metadata' => $page['metadata'] ?? [],
'search_query' => isset($_GET['search']) ? htmlspecialchars($_GET['search']) : '',
'menu' => $this->renderMenu($menu),
'breadcrumb' => $breadcrumb,
@@ -930,7 +1010,7 @@ class CodePressCMS {
'lang_switch_url' => isset($_GET['guide']) ? '&guide' : '&page=' . $this->config['default_page'],
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $this->config['author']['website'] ?? '#',
'author_git' => $this->config['author']['git'] ?? '#',
'author_git' => 'https://git.noorlander.info/E.Noorlander',
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
'cms_version' => isset($this->config['version_info']) ? 'v' . $this->config['version_info']['version'] : '',
@@ -939,6 +1019,8 @@ class CodePressCMS {
'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff',
'navigation_color' => $this->config['theme']['navigation_color'] ?? '#f8f9fa',
'navigation_font_color' => $this->config['theme']['navigation_font_color'] ?? '#000000',
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
// Language
'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage),
@@ -967,17 +1049,20 @@ class CodePressCMS {
't_page_not_found' => $this->t('page_not_found'),
't_page_not_found_text' => $this->t('page_not_found_text'),
't_mappen' => $this->t('mappen'),
't_paginas' => $this->t('paginas')
't_paginas' => $this->t('paginas'),
't_author_website' => $this->t('author_website'),
't_author_git' => $this->t('author_git')
];
// File info for footer
if (isset($page['file_info'])) {
$templateData['file_info'] = $this->t('created') . ': ' . htmlspecialchars($page['file_info']['created']) .
' | ' . $this->t('modified') . ': ' . htmlspecialchars($page['file_info']['modified']);
$templateData['file_info_block'] = '<span class="file-details"> | ' . $templateData['file_info'] . '</span>';
$templateData['created'] = htmlspecialchars($page['file_info']['created']);
$templateData['modified'] = htmlspecialchars($page['file_info']['modified']);
$templateData['file_info_block'] = true;
} else {
$templateData['file_info'] = '';
$templateData['file_info_block'] = '';
$templateData['created'] = '';
$templateData['modified'] = '';
$templateData['file_info_block'] = false;
}
@@ -1025,9 +1110,12 @@ class CodePressCMS {
*
* @return string Breadcrumb HTML
*/
private function generateBreadcrumb() {
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" 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"><li class="breadcrumb-item"><a href="?page=' . $this->config['default_page'] . '&lang=' . $this->currentLanguage . '"></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>';
}
$page = $_GET['page'] ?? $this->config['default_page'];
@@ -1035,12 +1123,13 @@ class CodePressCMS {
$page = preg_replace('/\.[^.]+$/', '', $page);
if ($page === $this->config['default_page']) {
return '<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item active"><i class="bi bi-house"></i></li></ol></nav>';
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item active"><i class="bi bi-house"></i></li></ol></nav>';
}
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
// Start with home icon linking to default page (root)
// Start with sidebar toggle, then home icon linking to default page (root)
$breadcrumb .= $sidebarToggle;
$breadcrumb .= '<li class="breadcrumb-item"><a href="?page=' . $this->config['default_page'] . '&lang=' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>';
// Split page path and build breadcrumb items
@@ -1049,14 +1138,15 @@ class CodePressCMS {
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>';
}
}
@@ -1146,7 +1236,6 @@ class CodePressCMS {
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

@@ -37,6 +37,16 @@ class SimpleTemplate {
// Handle partial includes first ({{>partial}})
$template = preg_replace_callback('/{{>([^}]+)}}/', [$this, 'replacePartial'], $template);
// Handle equal conditionals first
$template = preg_replace_callback('/{{#equal\s+(\w+)\s+["\']([^"\']+)["\']}}(.*?){{\/equal}}/s', function($matches) {
$key = $matches[1];
$expectedValue = $matches[2];
$content = $matches[3];
$actualValue = $this->data[$key] ?? '';
return ($actualValue === $expectedValue) ? $content : '';
}, $template);
// Handle conditional blocks
foreach ($this->data as $key => $value) {
if (is_array($value)) {

View File

@@ -26,12 +26,22 @@
// Load configuration system - handles default settings and config.json merging
require_once 'config.php';
// Load Composer autoloader
$autoloader = dirname(__DIR__, 2) . '/vendor/autoload.php';
if (file_exists($autoloader)) {
require_once $autoloader;
}
// Load template engine - renders HTML with {{variable}} placeholders and conditionals
require_once 'class/SimpleTemplate.php';
// Load Logger class - structured logging with log levels
require_once 'class/Logger.php';
// Load Plugin system
require_once 'plugin/CMSAPI.php';
require_once 'plugin/PluginManager.php';
// Load main CMS class - handles content parsing, navigation, search, and page rendering
require_once 'class/CodePressCMS.php';

View File

@@ -0,0 +1,223 @@
<?php
class CMSAPI
{
private CodePressCMS $cms;
public function __construct(CodePressCMS $cms)
{
$this->cms = $cms;
}
/**
* Get current page information
*/
public function getCurrentPage(): array
{
return $this->cms->getPage();
}
/**
* Get current page title
*/
public function getCurrentPageTitle(): string
{
$page = $this->cms->getPage();
return $page['title'] ?? '';
}
/**
* Get current page content
*/
public function getCurrentPageContent(): string
{
$page = $this->cms->getPage();
return $page['content'] ?? '';
}
/**
* Get current page URL
*/
public function getCurrentPageUrl(): string
{
$page = $_GET['page'] ?? $this->cms->config['default_page'];
$lang = $_GET['lang'] ?? $this->cms->config['language']['default'] ?? 'nl';
return "?page={$page}&lang={$lang}";
}
/**
* Get menu structure
*/
public function getMenu(): array
{
return $this->cms->getMenu();
}
/**
* Get configuration value
*/
public function getConfig(string $key, $default = null)
{
$keys = explode('.', $key);
$value = $this->cms->config;
foreach ($keys as $k) {
if (!isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get translation
*/
public function translate(string $key): string
{
return $this->cms->t($key);
}
/**
* Get current language
*/
public function getCurrentLanguage(): string
{
return $this->cms->currentLanguage;
}
/**
* Check if user is on homepage
*/
public function isHomepage(): bool
{
$defaultPage = $this->cms->config['default_page'] ?? 'index';
$currentPage = $_GET['page'] ?? $defaultPage;
return $currentPage === $defaultPage;
}
/**
* Get file info for current page
*/
public function getCurrentPageFileInfo(): ?array
{
$page = $this->cms->getPage();
return $page['file_info'] ?? null;
}
/**
* Get breadcrumb data
*/
public function getBreadcrumb(): string
{
return $this->cms->generateBreadcrumb();
}
/**
* Check if content directory has content
*/
public function hasContent(): bool
{
return !$this->cms->isContentDirEmpty();
}
/**
* Get search results if searching
*/
public function getSearchResults(): array
{
if (isset($_GET['search'])) {
return $this->cms->searchResults;
}
return [];
}
/**
* Check if currently searching
*/
public function isSearching(): bool
{
return isset($_GET['search']);
}
/**
* Get available languages
*/
public function getAvailableLanguages(): array
{
return $this->cms->getAvailableLanguages();
}
/**
* Create URL for page
*/
public function createUrl(string $page, ?string $lang = null): string
{
$lang = $lang ?? $this->getCurrentLanguage();
return "?page={$page}&lang={$lang}";
}
/**
* Execute PHP file and capture output
*/
public function executePhpFile(string $filePath): string
{
if (!file_exists($filePath)) {
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();
}
/**
* Get content from PHP/HTML/Markdown file
*/
public function getFileContent(string $filePath): string
{
if (!file_exists($filePath)) {
return '';
}
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
switch ($extension) {
case 'php':
return $this->executePhpFile($filePath);
case 'md':
$content = file_get_contents($filePath);
$result = $this->cms->parseMarkdown($content, $filePath);
return $result['content'] ?? '';
case 'html':
return file_get_contents($filePath);
default:
return file_get_contents($filePath);
}
}
/**
* Check if file exists in content directory
*/
public function contentFileExists(string $filename): bool
{
$contentDir = $this->cms->config['content_dir'];
return file_exists($contentDir . '/' . $filename);
}
/**
* Get all pages with their metadata
*/
public function getAllPages(): array
{
return $this->cms->getAllPageTitles();
}
}

View File

@@ -0,0 +1,77 @@
<?php
class PluginManager
{
private array $plugins = [];
private string $pluginsPath;
private ?CMSAPI $api = null;
public function __construct(string $pluginsPath)
{
$this->pluginsPath = $pluginsPath;
$this->loadPlugins();
}
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
// Inject API into all plugins that have setAPI method
foreach ($this->plugins as $plugin) {
if (method_exists($plugin, 'setAPI')) {
$plugin->setAPI($api);
}
}
}
private function loadPlugins(): void
{
if (!is_dir($this->pluginsPath)) {
return;
}
$pluginDirs = glob($this->pluginsPath . '/*', GLOB_ONLYDIR);
foreach ($pluginDirs as $pluginDir) {
$pluginName = basename($pluginDir);
$pluginFile = $pluginDir . '/' . $pluginName . '.php';
if (file_exists($pluginFile)) {
require_once $pluginFile;
$className = $pluginName;
if (class_exists($className)) {
$this->plugins[$pluginName] = new $className();
// Inject API if already available
if ($this->api && method_exists($this->plugins[$pluginName], 'setAPI')) {
$this->plugins[$pluginName]->setAPI($this->api);
}
}
}
}
}
public function getPlugin(string $name): ?object
{
return $this->plugins[$name] ?? null;
}
public function getAllPlugins(): array
{
return $this->plugins;
}
public function getSidebarContent(): string
{
$sidebarContent = '';
foreach ($this->plugins as $plugin) {
if (method_exists($plugin, 'getSidebarContent')) {
$sidebarContent .= $plugin->getSidebarContent();
}
}
return $sidebarContent;
}
}

View File

@@ -22,5 +22,19 @@ return [
'page_not_found' => 'Page Not Found',
'page_not_found_text' => 'The page you are looking for does not exist.',
'mappen' => 'Folders',
'paginas' => 'Pages'
'paginas' => 'Pages',
'author_website' => 'Author website',
'author_git' => 'Author Git',
'plugins' => 'Plugins',
'templates' => 'Templates',
'layouts' => 'Layouts',
'sidebar_content' => 'Sidebar + Content',
'content_only' => 'Content Only',
'sidebar_only' => 'Sidebar Only',
'content_sidebar' => 'Content + Sidebar',
'plugin_development' => 'Plugin Development',
'template_system' => 'Template System',
'mqtt_tracking' => 'MQTT Tracking',
'real_time_analytics' => 'Real-time Analytics',
'go_to' => 'Go to'
];

View File

@@ -22,5 +22,19 @@ return [
'page_not_found' => 'Pagina niet gevonden',
'page_not_found_text' => 'De pagina die u zoekt bestaat niet.',
'mappen' => 'Mappen',
'paginas' => 'Pagina\'s'
'paginas' => 'Pagina\'s',
'author_website' => 'Auteur website',
'author_git' => 'Auteur Git',
'plugins' => 'Plugins',
'templates' => 'Templates',
'layouts' => 'Layouts',
'sidebar_content' => 'Sidebar + Content',
'content_only' => 'Alleen Content',
'sidebar_only' => 'Alleen Sidebar',
'content_sidebar' => 'Content + Sidebar',
'plugin_development' => 'Plugin Ontwikkeling',
'template_system' => 'Template Systeem',
'mqtt_tracking' => 'MQTT Tracking',
'real_time_analytics' => 'Real-time Analytics',
'go_to' => 'Ga naar'
];

View File

@@ -1,20 +1,39 @@
<footer class="bg-light border-top py-3">
<footer class="bg-light border-top py-1">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center">
<div class="file-info">
<i class="bi bi-file-text"></i>
<span class="page-title" title="{{page_title}}">{{page_title}}</span>
{{{file_info_block}}}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
<div class="file-info mb-2 mb-md-0">
<small class="text-muted">
<i class="bi bi-file-text footer-icon" title="{{t_file_details}}: {{page_title}}"></i>
<span class="page-title d-none d-lg-inline" title="{{page_title}}">{{page_title}}</span>
{{#file_info_block}}
<span class="ms-2">
<i class="bi bi-calendar-plus footer-icon" title="{{t_created}}: {{created}}"></i>
<span class="file-created">{{created}}</span>
<i class="bi bi-calendar-check ms-1 footer-icon" title="{{t_modified}}: {{modified}}"></i>
<span class="file-modified">{{modified}}</span>
</span>
{{/file_info_block}}
</small>
</div>
<div class="site-info">
<small class="text-muted">
<a href="?guide&lang={{current_lang}}" class="guide-link" title="{{t_guide}}">
<a href="?guide&lang={{current_lang}}" class="footer-icon guide" title="{{t_guide}}">
<i class="bi bi-book"></i>
</a>
<span class="ms-2">|</span>
{{t_powered_by}} <a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener">CodePress CMS</a> {{cms_version}}
<span class="ms-1">|</span>
<a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener" class="footer-icon cms" title="{{t_powered_by}} CodePress CMS {{cms_version}}">
<i class="bi bi-cpu"></i>
</a>
<span class="ms-1">|</span>
<a href="{{author_website}}" target="_blank" rel="noopener" class="footer-icon website" title="{{t_author_website}}">
<i class="bi bi-globe"></i>
</a>
<span class="ms-1">|</span>
<a href="{{author_git}}" target="_blank" rel="noopener" class="footer-icon git" title="{{t_author_git}}">
<i class="bi bi-git"></i>
</a>
</small>
</div>
</div>

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">
@@ -34,6 +34,8 @@
--header-font: {{header_font_color}};
--nav-bg: {{navigation_color}};
--nav-font: {{navigation_font_color}};
--sidebar-bg: {{sidebar_background}};
--sidebar-border: {{sidebar_border}};
}
/* Header styles */
@@ -125,6 +127,37 @@
text-decoration: underline;
}
/* Sidebar toggle button in breadcrumb */
.sidebar-toggle-item {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.sidebar-toggle-btn {
padding: 0;
line-height: 1;
font-size: 1.1rem;
color: var(--header-bg) !important;
border: none !important;
background: transparent !important;
box-shadow: none !important;
cursor: pointer;
}
.sidebar-toggle-btn:hover {
opacity: 0.7;
}
/* Sidebar hide/show transition */
.sidebar-column {
transition: all 0.3s ease;
}
.sidebar-hidden {
display: none !important;
}
/* Navigation section background */
.navigation-section {
background-color: var(--nav-bg) !important;
@@ -151,14 +184,112 @@
background-color: rgba(255,255,255,0.2) !important;
border-bottom: 2px solid var(--nav-font) !important;
}
/* Sidebar styling */
.sidebar-column {
background-color: var(--sidebar-bg) !important;
border-right: 1px solid var(--sidebar-border) !important;
position: sticky;
top: 0;
height: 100vh;
}
.sidebar {
padding: 1.5rem;
height: 100%;
overflow-y: auto;
}
.content-column {
background-color: #ffffff;
}
.content-wrapper {
padding: 2rem;
padding-bottom: 80px !important;
}
/* Ensure full height layout */
.main-content {
min-height: calc(100vh - 200px);
}
/* Mobile responsive */
@media (max-width: 767.98px) {
.sidebar-column {
border-right: none !important;
border-top: 1px solid var(--sidebar-border) !important;
min-height: auto;
margin-top: 1rem;
}
.content-column {
background-color: #ffffff;
}
.content-wrapper {
min-height: auto;
padding-bottom: 2rem !important;
}
}
/* Tablet and mobile: sidebar below content */
@media (max-width: 991.98px) {
.sidebar-column {
order: 2 !important;
}
.content-column {
order: 1 !important;
}
}
/* Footer icon hover effects */
.footer-icon {
color: #6c757d;
text-decoration: none;
transition: all 0.2s ease-in-out;
display: inline-block;
padding: 2px;
}
.footer-icon:hover {
color: #0d6efd;
transform: translateY(-1px);
}
.footer-icon:active {
transform: translateY(0);
}
/* Specific icon hover colors */
.footer-icon.guide:hover {
color: #198754;
}
.footer-icon.cms:hover {
color: #dc3545;
}
.footer-icon.git:hover {
color: #6f42c1;
}
.footer-icon.website:hover {
color: #fd7e14;
}
</style>
</head>
<body>
{{>header}}
<header id="site-header">
{{>header}}
</header>
{{>navigation}}
<nav id="site-navigation">
{{>navigation}}
</nav>
<div class="breadcrumb-section bg-light border-bottom">
<div id="site-breadcrumb" class="breadcrumb-section bg-light border-bottom">
<div class="container-fluid">
<div class="row">
<div class="col-12 py-2">
@@ -168,17 +299,87 @@
</div>
</div>
<div class="container-fluid main-content" style="padding-bottom: 80px;">
<div class="container">
<div class="row">
<main class="col-12">
{{>content_template}}
</main>
<main id="site-main" class="main-content" style="padding: 0;">
{{#sidebar_content}}
{{#equal layout "sidebar-content"}}
<div class="row g-0">
<aside id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2 order-md-1">
<div class="sidebar h-100">
{{{sidebar_content}}}
</div>
</aside>
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1 order-md-2">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
</div>
{{/equal}}
{{#equal layout "content"}}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
</div>
{{/equal}}
{{#equal layout "sidebar"}}
<div class="container-fluid">
<aside id="site-sidebar" class="col-12 sidebar-column">
<div class="sidebar">
{{{sidebar_content}}}
</div>
</aside>
</div>
{{/equal}}
{{#equal layout "content-sidebar"}}
<div class="row g-0">
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
<aside id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2">
<div class="sidebar h-100">
{{{sidebar_content}}}
</div>
</aside>
</div>
{{/equal}}
{{#equal layout "content-sidebar-reverse"}}
<div class="row g-0 flex-row-reverse">
<section id="site-content" class="col-lg-9 col-md-8 content-column">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
<aside id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column">
<div class="sidebar h-100">
{{{sidebar_content}}}
</div>
</aside>
</div>
{{/equal}}
{{/sidebar_content}}
{{^sidebar_content}}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
</div>
</div>
{{/sidebar_content}}
</main>
{{>footer}}
<footer id="site-footer">
{{>footer}}
</footer>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>

View File

@@ -1,191 +1,80 @@
# CodePress CMS Guide
## Welcome to CodePress CMS
## Welcome to CodePress
CodePress is a lightweight, file-based Content Management System built with PHP and Bootstrap.
### Table of Contents
## Features
1. [Getting Started](#getting-started)
2. [Content Management](#content-management)
3. [Templates](#templates)
4. [Configuration](#configuration)
### 🏠 Navigation
- Tab-style navigation with Bootstrap styling
- Dropdown menus for folders and sub-folders
- Home button with icon
- Automatic menu generation
- Responsive design
- 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
- **PHP (.php)** - Dynamic content
- **HTML (.html)** - Static HTML pages
- **Directory listings** - Automatic directory overviews
- **Language-specific content** - `en.` and `nl.` prefixes
## Getting Started
### 🔍 Search Functionality
- Full-text search through all content
- Results with snippets and highlighting
- Direct navigation to found pages
- SEO-friendly search results
- Search URL: `?search=query`
### Requirements
- PHP 8.4+
- Web server (Apache/Nginx)
- Modern web browser
- Write permissions for content directory
### 🧭 Configuration
- **JSON configuration** in `config.json`
- Dynamic homepage setting
- SEO settings (description, keywords)
- Author information with links
- Theme configuration with colors
- Language settings
- Feature toggles
### Installation
1. Clone or download the CodePress files
### 🎨 Layout & Design
- Flexbox layout for responsive structure
- Fixed header with logo and search
- Breadcrumb navigation
- Fixed footer with file info and links
- Bootstrap 5 styling
- Mustache templates
- Semantic HTML5 structure
- **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
2. Upload to your web server
3. Make sure the `content/` directory is writable
4. Navigate to your website in the browser
3. Make sure `content/` directory is writable
4. Navigate to your website in browser
### Basic Configuration
The most important settings are in `engine/core/config.php`:
```php
$config = [
'site_title' => 'My Website',
'default_page' => 'home',
'content_dir' => __DIR__ . '/../../content',
'templates_dir' => __DIR__ . '/../templates'
];
```
---
## Content Management {#content-management}
### File Structure
```
content/
├── home.md # Home page
├── blog/
│ ├── index.md # Blog overview
│ ├── article-1.md # Blog article
│ └── category/
│ └── article.md # Article in category
└── about-us/
└── info.md # About us page
```
### Content Types
CodePress supports four content types:
#### Markdown (`.md`)
```markdown
# Page Title
This is page content in **Markdown** format with CommonMark extensions.
## Subsection
- [x] Task list item
- [ ] Another task
- **Bold** and *italic* text
- [Auto-linked pages](?page=another-page)
```
#### PHP (`.php`)
```php
<?php
$title = "Dynamic Page";
?>
<h1><?php echo htmlspecialchars($title); ?></h1>
<p>This is dynamic content with PHP.</p>
```
#### HTML (`.html`)
```html
<h1>HTML Page</h1>
<p>This is static HTML content.</p>
```
#### Directory Listings
Directories automatically generate listings:
- File information (size, dates)
- Navigation to subdirectories
- Responsive file listing layout
#### PHP (`.php`)
```php
<?php
$title = "Dynamic Page";
?>
<h1><?php echo $title; ?></h1>
<p>This is dynamic content with PHP.</p>
```
#### HTML (`.html`)
```html
<h1>HTML Page</h1>
<p>This is static HTML content.</p>
```
### Automatic Linking
CodePress automatically creates links to other pages when you mention page names in your content.
### Language Support
- **Browser detection**: Automatic language detection
- **URL switching**: `?lang=en` or `?lang=nl`
- **Language prefixes**: `en.page.md` and `nl.page.md`
- **Translation files**: `lang/en.php` and `lang/nl.php`
### Search Functionality
- **Full-text search**: Search through all content
- **Search URL**: `?search=query` for bookmarkable searches
- **Result highlighting**: Search terms highlighted in results
- **File and content search**: Searches both filenames and content
---
## Templates {#templates}
### Template Structure
CodePress uses Mustache-compatible templates:
- `layout.mustache` - Main template
- `assets/header.mustache` - Header component
- `assets/sidebar.mustache` - Sidebar navigation
- `assets/footer.mustache` - Footer component
### Template Variables
Available variables in templates:
#### **Site Info**
- `{{site_title}}` - Website title
- `{{author_name}}` - Author name
- `{{author_website}}` - Author website
- `{{author_git}}` - Git repository link
#### **Page Info**
- `{{page_title}}` - Page title (filename without extension)
- `{{content}}` - Page content (HTML)
- `{{file_info}}` - File information (dates, size)
- `{{is_homepage}}` - Boolean: is this the homepage?
#### **Navigation**
- `{{menu}}` - Navigation menu
- `{{breadcrumb}}` - Breadcrumb navigation
- `{{homepage}}` - Homepage link
- `{{homepage_title}}` - Homepage title
#### **Theme**
- `{{header_color}}` - Header background color
- `{{header_font_color}}` - Header text color
- `{{navigation_color}}` - Navigation background color
- `{{navigation_font_color}}` - Navigation text color
#### **Language**
- `{{current_lang}}` - Current language (en/nl)
- `{{current_lang_upper}}` - Current language (EN/NL)
- `{{available_langs}}` - Available languages
- `{{t_*}}` - Translated strings (t_home, t_search, etc.)
#### **SEO**
- `{{seo_description}}` - Meta description
- `{{seo_keywords}}` - Meta keywords
#### **Features**
- `{{has_content}}` - Boolean: is content available?
- `{{show_site_link}}` - Boolean: show site link?
- `{{is_guide_page}}` - Boolean: is this the guide page?
---
## Configuration {#configuration}
## Configuration
### Basic Settings
Edit `config.json` in your project root:
```json
\`\`\`json
{
"site_title": "Your Website Name",
"content_dir": "content",
@@ -199,12 +88,13 @@ Edit `config.json` in your project root:
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff"
"navigation_font_color": "#ffffff",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6"
},
"author": {
"name": "Your Name",
"website": "https://yourwebsite.com",
"git": "https://github.com/youruser/codepress"
"website": "https://yourwebsite.com"
},
"seo": {
"description": "Your website description",
@@ -216,72 +106,234 @@ Edit `config.json` in your project root:
"breadcrumbs_enabled": true
}
}
```
\`\`\`
### SEO Friendly URLs
CodePress automatically generates clean URLs:
- `home.md``?page=home`
- `blog/article.md``?page=blog/article`
- `nl.page.md``?page=nl.page&lang=nl`
## Content Management
### Language Support
- **Browser detection**: Automatic language detection
- **URL switching**: `?lang=en` or `?lang=nl`
### File Structure
\`\`\`
content/
├── home.md # Home page
├── blog/
│ ├── index.md # Blog overview
│ ├── article-1.md # Blog article
│ └── category/
│ └── article.md # Article in category
└── about-us/
└── info.md # About us page
\`\`\`
### Content Types
#### Markdown (`.md`)
\`\`\`markdown
# Page Title
This is page content in **Markdown** format with CommonMark extensions.
## Subsection
- [x] Task list item
- [ ] Another task
- **Bold** and *italic* text
- [Auto-linked pages](?page=another-page)
\`\`\`
#### PHP (`.php`)
\`\`\`php
<?php
$title = "Dynamic Page";
?>
<h1><?php echo htmlspecialchars($title); ?></h1>
<p>This is dynamic content with PHP.</p>
\`\`\`
#### HTML (`.html`)
\`\`\`html
<h1>HTML Page</h1>
<p>This is static HTML content.</p>
\`\`\`
### File Naming Conventions
- **Lowercase names**: Use lowercase for all files
- **No spaces**: Use hyphens (-) or underscores (_)
- **Language prefixes**: `en.page.md` and `nl.page.md`
- **Directory precedence**: Directories take precedence over files
- **Display names**: `file-name.md` displays as "File Name" in menus
### Search Functionality
The built-in search function searches through:
- File names
- Content of Markdown/PHP/HTML files
- Search URL: `?search=query` for bookmarkable searches
- Result highlighting and snippets
## Templates
### Directory Listings
- **Auto-generation**: `?page=directory` shows directory contents
- **File information**: Creation/modification dates and sizes
- **Navigation**: Links to files and subdirectories
### Template Variables
#### Site Info
- `site_title` - Website title
- `author_name` - Author name
- `author_website` - Author website
- `author_git` - Git repository link
#### Page Info
- `page_title` - Page title (filename without extension)
- `content` - Page content (HTML)
- `file_info` - File information (dates, size)
- `is_homepage` - Boolean: is this homepage?
#### Navigation
- `menu` - Navigation menu
- `breadcrumb` - Breadcrumb navigation
- `homepage` - Homepage link
#### Theme
- `header_color` - Header background color
- `header_font_color` - Header text color
- `navigation_color` - Navigation background color
- `navigation_font_color` - Navigation text color
#### Language
- `current_lang` - Current language (en/nl)
- `current_lang_upper` - Current language (EN/NL)
- `t_*` - Translated strings
## URL Structure
### Basic URLs
- **Home**: `/` or `?page=home`
- **Page**: `?page=blog/article`
- **Search**: `?search=query`
- **Guide**: `?guide`
- **Language**: `?lang=en` or `?lang=nl`
## SEO Optimization
### Meta Tags
The CMS automatically adds meta tags:
\`\`\`html
<meta name="generator" content="CodePress CMS">
<meta name="application-name" content="CodePress">
<meta name="author" content="Your Name">
<meta name="description" content="...">
<meta name="keywords" content="...">
<link rel="author" href="https://yourwebsite.com">
<link rel="me" href="https://git.noorlander.info/E.Noorlander/CodePress">
\`\`\`
## 🔌 Plugin System
### Plugin Structure
\`\`\`
plugins/
├── README.md # Plugin documentation
├── HTMLBlock/
│ ├── HTMLBlock.php # Plugin class
│ └── README.md # Plugin specific documentation
└── MQTTTracker/
├── MQTTTracker.php # Plugin class
├── config.json # Plugin configuration
└── README.md # Plugin documentation
\`\`\`
### Plugin Development
- **API access** via `CMSAPI` class
- **Sidebar content** with `getSidebarContent()`
- **Metadata access** from YAML frontmatter
- **Configuration** via JSON files
- **Event hooks** for extension
### Available Plugins
- **HTMLBlock** - Custom HTML blocks in sidebar
- **MQTTTracker** - Real-time analytics and tracking
## 🎯 Template System
### Layout Options
Use YAML frontmatter to select layout:
\`\`\`yaml
---
title: My Page
layout: sidebar-content
---
\`\`\`
### Available Layouts
- `sidebar-content` - Sidebar left, content right (default)
- `content` - Content only (full width)
- `sidebar` - Sidebar only
- `content-sidebar` - Content left, sidebar right
- `content-sidebar-reverse` - Content right, sidebar left
### Meta Data
\`\`\`yaml
---
title: Page Title
layout: content-sidebar
description: Page description
author: Author Name
date: 2025-11-26
---
\`\`\`
## 📊 Analytics & Tracking
### MQTT Tracker
- Real-time page tracking
- Session management
- Business Intelligence data
- Privacy aware (GDPR compliant)
- MQTT integration for dashboards
### Data Format
\`\`\`json
{
"timestamp": "2025-11-26T15:30:00+00:00",
"session_id": "cms_1234567890abcdef",
"page_url": "?page=demo/sidebar-content&lang=en",
"page_title": "Sidebar-Content Layout",
"language": "en",
"layout": "sidebar-content"
}
\`\`\`
## Tips and Tricks
### Page Organization
- Use subdirectories for categories
- Give each directory an `index.md` for an overview page
- Keep file names short and descriptive
- Use language prefixes: `en.page.md` and `nl.page.md`
- Directory names take precedence over files with same name
### File Naming Conventions
- **Lowercase names**: Use lowercase for all files
- **No spaces**: Use hyphens (-) or underscores (_)
- **Language prefixes**: `en.` or `nl.` for multilingual content
- **Display names**: `file-name.md` displays as "File Name" in menus
- **Special cases**: `phpinfo` → "phpinfo", `ict` → "ICT"
### Content Optimization
- Use clear headings (H1, H2, H3)
- Add descriptive meta information
- Use internal links for better navigation
### Security
- Keep your CodePress installation updated
- Restrict write permissions on the `content/` directory
- Use HTTPS when possible
## Troubleshooting
---
### Common Issues
## Support
### Troubleshooting
- **Empty pages**: Check file permissions
- **Template errors**: Verify template syntax
- **404 errors**: Check file names and paths
- **Navigation not updated**: Reload the page
## Support
### More Information
- Documentation: [CodePress GitHub](https://git.noorlander.info/E.Noorlander/CodePress.git)
- Issues and feature requests: GitHub Issues
- Documentation: [CodePress Git](https://git.noorlander.info/E.Noorlander/CodePress)
- Issues and feature requests: [Git Issues](https://git.noorlander.info/E.Noorlander/CodePress/issues)
---

View File

@@ -2,84 +2,82 @@
## Overzicht
CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd met PHP. Het is ontworpen om eenvoudig te gebruiken, flexibel te zijn en zonder database te werken.
CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd met PHP. Werkt zonder database.
## Functies
### 🏠 **Navigatie**
- **Tab-style navigatie** met Bootstrap styling
- **Dropdown menus** voor mappen en sub-mappen
- **Home knop** met icoon die linkt naar de ingestelde homepage
- **Automatische menu generatie** op basis van content structuur
- **Responsive design** voor mobiele apparaten
- **Breadcrumb navigatie** met home icoon en pad weergave
- **Active state marking** voor huidige pagina in menu
### 🏠 Navigatie
- Tab-style navigatie met Bootstrap styling
- Dropdown menus voor mappen en sub-mappen
- Home knop met icoon
- Automatische menu generatie
- Responsive design
- 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)** - Met CommonMark ondersteuning en extensies (autolink, strikethrough, tables, task lists)
- **PHP (.php)** - Voor dynamische content en functionaliteit
- **HTML (.html)** - Voor statische HTML pagina's
- **Directory listings** - Automatische generatie van directory overzichten
- **Language-specific content** - `nl.` en `en.` prefix voor meertalige content
- **Automatische template selectie** op basis van bestandstype
### 📄 Content Types
- **Markdown (.md)** - CommonMark ondersteuning
- **PHP (.php)** - Dynamische content
- **HTML (.html)** - Statische HTML pagina's
- **Directory listings** - Automatische directory overzichten
- **Language-specific content** - `nl.` en `en.` prefix
### 🔍 **Zoekfunctionaliteit**
- **Volledige tekst zoek** door alle content bestanden
- **Resultaten met snippets** en highlighting
- **Directe navigatie** naar gevonden pagina's
- **SEO-vriendelijke** zoekresultaten
- **Search URL**: `?search=zoekterm` voor bookmarkable searches
### 🔍 Zoekfunctionaliteit
- Volledige tekst zoek door alle content
- Resultaten met snippets en highlighting
- Directe navigatie naar gevonden pagina's
- SEO-vriendelijke zoekresultaten
- Search URL: `?search=zoekterm`
### 🧭 **Configuratie**
- **JSON configuratie** in project root (`config.json`)
- **Dynamische homepage** instelling of automatische detectie
- **SEO instellingen** (description, keywords)
- **Author informatie** met links naar website en Git
- **Thema configuratie** met kleur instellingen
- **Language settings** voor meertalige content
- **Feature toggles** voor search, breadcrumbs, auto-linking
### 🧭 Configuratie
- **JSON configuratie** in `config.json`
- Dynamische homepage instelling
- SEO instellingen (description, keywords)
- Author informatie met links
- Thema configuratie met kleuren
- Language settings
- Feature toggles
### 🎨 **Layout & Design**
- **Flexbox layout** voor moderne, responsive structuur
- **Fixed header** met logo en zoekfunctie
- **Breadcrumb navigatie** tussen header en content
- **Fixed footer** met file info en links
- **Bootstrap 5** styling en componenten
- **Custom CSS** voor specifieke styling
- **Mustache templates** met conditionals en partials
- **Semantic HTML5** structuur voor SEO
### 🎨 Layout & Design
- Flexbox layout voor responsive structuur
- Fixed header met logo en zoekfunctie
- Breadcrumb navigatie
- Fixed footer met file info en links
- Bootstrap 5 styling
- Mustache templates
- Semantic HTML5 structuur
- **Dynamic layouts** met YAML frontmatter
- **Sidebar support** met plugin integratie en toggle functie via breadcrumb
### 📱 **Responsive Features**
- **Mobile-first** aanpak
- **Hamburger menu** voor kleine schermen
- **Touch-friendly** dropdowns en navigatie
- **Adaptieve breedtes** voor verschillende schermgroottes
### 🌍 **Language Support**
- **Browser language detection** op basis van Accept-Language header
- **URL language switching**: `?lang=nl` of `?lang=en`
- **Translation files**: `lang/nl.php` en `lang/en.php`
- **Language prefixes**: `nl.bestand.md` en `en.bestand.md`
- **Automatic language routing** voor meertalige content
### 🛡️ 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
2. **Stel permissies in** voor webserver
3. **Configureer** `config.json` indien nodig
4. **Toegang** tot website via browser
1. Upload bestanden naar webserver
2. Stel permissies in voor webserver
3. Configureer `config.json` indien nodig
4. Toegang tot website via browser
## Configuratie
### Basis Configuratie (`config.json`)
```json
\`\`\`json
{
"site_title": "CodePress",
"content_dir": "content",
"templates_dir": "engine/templates",
"default_page": "auto",
"homepage": "welkom",
"language": {
"default": "nl",
"available": ["nl", "en"]
@@ -88,12 +86,13 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff"
"navigation_font_color": "#ffffff",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6"
},
"author": {
"name": "Edwin Noorlander",
"website": "https://noorlander.info",
"git": "https://git.noorlander.info/E.Noorlander/CodePress.git"
"name": "E. Noorlander",
"website": "https://noorlander.info"
},
"seo": {
"description": "CodePress CMS - Lightweight file-based content management system",
@@ -105,27 +104,13 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
"breadcrumbs_enabled": true
}
}
```
### Configuratie Opties
- **`site_title`** - Naam van de website
- **`content_dir`** - Map met content bestanden
- **`templates_dir`** - Map met template bestanden
- **`default_page`** - Standaard pagina (`"auto"` voor automatische detectie)
- **`homepage`** - Specifieke homepage (`"auto"` voor automatische detectie)
- **`language.default`** - Standaard taal
- **`language.available`** - Beschikbare talen
- **`theme.*`** - Kleur instellingen voor header en navigatie
- **`author`** - Auteur informatie met links
- **`seo`** - SEO instellingen
- **`features.*`** - Feature toggles (search, breadcrumbs, auto-linking)
\`\`\`
## Content Structuur
### Bestandsstructuur
```
\`\`\`
content/
├── map1/
│ ├── submap1/
@@ -136,74 +121,47 @@ content/
│ └── pagina4.md
├── homepage.md
└── index.html
```
\`\`\`
### Bestandsnamen
- **Gebruik lowercase** bestandsnamen
- **Geen spaties** - gebruik `-` of `_` als scheidingsteken
- **Logische extensies** - `.md`, `.php`, `.html`
- **Unieke namen** - geen duplicaten binnen dezelfde map
- **Language prefixes** - `nl.bestand.md` en `en.bestand.md` voor meertalige content
- **Display names** - `bestands-naam` wordt automatisch "Bestands Naam" in menu's
- **Special cases** - `phpinfo` → "phpinfo", `ict` → "ICT"
- Gebruik lowercase bestandsnamen
- Geen spaties - gebruik `-` of `_`
- Logische extensies - `.md`, `.php`, `.html`
- Unieke namen - geen duplicaten
- Language prefixes - `nl.bestand.md` en `en.bestand.md`
## Templates
### Template Structuur
```
engine/templates/
├── layout.mustache - Hoofd layout template
├── assets/
│ ├── header.mustache - Header template
│ ├── navigation.mustache - Navigatie template
│ └── footer.mustache - Footer template
├── markdown_content.mustache - Markdown content template
├── php_content.mustache - PHP content template
└── html_content.mustache - HTML content template
```
### Template Variabelen
#### **Site Info**
- **`{{site_title}}`** - Website titel
- **`{{author_name}}`** - Auteur naam
- **`{{author_website}}`** - Auteur website
- **`{{author_git}}`** - Git repository link
#### Site Info
- `site_title` - Website titel
- `author_name` - Auteur naam
- `author_website` - Auteur website
- `author_git` - Git repository link
#### **Page Info**
- **`{{page_title}}`** - Pagina titel (bestandsnaam zonder extensie)
- **`{{content}}`** - Content (HTML)
- **`{{file_info}}`** - Bestandsinformatie (datum, grootte)
- **`{{is_homepage}}`** - Boolean: is dit de homepage?
#### Page Info
- `page_title` - Pagina titel
- `content` - Content (HTML)
- `file_info` - Bestandsinformatie
- `is_homepage` - Boolean: is dit de homepage?
#### **Navigation**
- **`{{menu}}`** - Navigatie menu
- **`{{breadcrumb}}`** - Breadcrumb navigatie
- **`{{homepage}}`** - Homepage link
- **`{{homepage_title}}`** - Homepage titel
#### Navigation
- `menu` - Navigatie menu
- `breadcrumb` - Breadcrumb navigatie
- `homepage` - Homepage link
#### **Theme**
- **`{{header_color}}`** - Header achtergrondkleur
- **`{{header_font_color}}`** - Header tekstkleur
- **`{{navigation_color}}`** - Navigatie achtergrondkleur
- **`{{navigation_font_color}}`** - Navigatie tekstkleur
#### Theme
- `header_color` - Header achtergrondkleur
- `header_font_color` - Header tekstkleur
- `navigation_color` - Navigatie achtergrondkleur
- `navigation_font_color` - Navigatie tekstkleur
#### **Language**
- **`{{current_lang}}`** - Huidige taal (nl/en)
- **`{{current_lang_upper}}`** - Huidige taal (NL/EN)
- **`{{available_langs}}`** - Beschikbare talen
- **`{{t_*}}`** - Vertaalde strings (t_home, t_search, etc.)
#### **SEO**
- **`{{seo_description}}`** - Meta description
- **`{{seo_keywords}}`** - Meta keywords
#### **Features**
- **`{{has_content}}`** - Boolean: is er content beschikbaar?
- **`{{show_site_link}}`** - Boolean: toon site link?
- **`{{is_guide_page}}`** - Boolean: is dit de handleiding pagina?
#### Language
- `current_lang` - Huidige taal (nl/en)
- `current_lang_upper` - Huidige taal (NL/EN)
- `t_*` - Vertaalde strings
## URL Structuur
@@ -214,107 +172,129 @@ engine/templates/
- **Handleiding**: `?guide`
- **Language**: `?lang=nl` of `?lang=en`
### File Extensions
Bestandsextensies worden automatisch gedetecteerd:
- `pagina.md``?page=pagina`
- `pagina.php``?page=pagina`
- `pagina.html``?page=pagina`
### Language Support
- **Browser detection**: Automatische taal detectie
- **URL switching**: `?page=nl.bestand&lang=nl`
- **Language prefixes**: `nl.bestand.md` en `en.bestand.md`
- **Directory precedence**: Directories hebben voorrang op bestanden
### Directory Listings
- **Auto-generation**: `?page=map` toont directory inhoud
- **File info**: Creatie/modificatie datums en groottes
- **Navigation**: Links naar bestanden en submappen
## SEO Optimalisatie
### Meta Tags
De CMS voegt automatisch de volgende meta tags toe:
```html
De CMS voegt automatisch meta tags toe:
\`\`\`html
<meta name="generator" content="CodePress CMS">
<meta name="application-name" content="CodePress">
<meta name="author" content="Edwin Noorlander">
<meta name="author" content="E. Noorlander">
<meta name="description" content="...">
<meta name="keywords" content="...">
<link rel="author" href="https://noorlander.info">
<link rel="me" href="https://git.noorlander.info/E.Noorlander/CodePress.git">
```
\`\`\`
### Auto-linking
## 🔌 Plugin Systeem
De CMS linkt automatisch pagina titels naar hun content:
### Plugin Structuur
- **Automatische detectie** van pagina titels in tekst
- **Slimme links** met `title` attributen
- **Geen dubbele links** voor dezelfde pagina
- **SEO-vriendelijke** URL structuur
\`\`\`
plugins/
├── README.md
├── HTMLBlock/
│ ├── HTMLBlock.php
│ └── README.md
└── MQTTTracker/
├── MQTTTracker.php
├── config.json
└── README.md
\`\`\`
### Plugin Development
- **API toegang** via `CMSAPI` class
- **Sidebar content** met `getSidebarContent()`
- **Metadata toegang** uit YAML frontmatter
- **Configuratie** via JSON bestanden
### Beschikbare Plugins
- **HTMLBlock** - Custom HTML blokken in sidebar
- **MQTTTracker** - Real-time analytics en tracking
## 🎯 Template Systeem
### Layout Opties
Gebruik YAML frontmatter om layout te selecteren:
\`\`\`yaml
---
title: Mijn Pagina
layout: sidebar-content
---
\`\`\`
### Beschikbare Layouts
- `sidebar-content` - Sidebar links, content rechts (standaard)
- `content` - Alleen content (volle breedte)
- `sidebar` - Alleen sidebar
- `content-sidebar` - Content links, sidebar rechts
- `content-sidebar-reverse` - Content rechts, sidebar links
### Meta Data
\`\`\`yaml
---
title: Pagina Titel
layout: content-sidebar
description: Pagina beschrijving
author: Auteur Naam
date: 2025-11-26
---
\`\`\`
## 📊 Analytics & Tracking
### MQTT Tracker
- Real-time page tracking
- Session management
- Business Intelligence data
- Privacy aware (GDPR compliant)
- MQTT integration voor dashboards
## Veelgestelde Vragen
### Hoe stel ik de homepage in?
1. **Automatisch**: Laat de CMS het eerste bestand kiezen
2. **Handmatig**: Stel `"homepage": "pagina-naam"` in `config.json`
3. **Flexibel**: Werkt met elk bestandstype (md, php, html)
2. **Handmatig**: Stel `"default_page": "pagina-naam"` in `config.json`
### Hoe werkt de navigatie?
- **Mappen** worden dropdown menus
- **Bestanden** worden directe links
- **Sub-mappen** worden geneste dropdowns
- **Home knop** linkt altijd naar de homepage
### Hoe voeg ik nieuwe content toe?
1. **Upload** bestanden naar de `content/` map
2. **Organiseer** in logische mappen
3. **Gebruik** juiste bestandsnamen en extensies
4. **Herlaad** de pagina om de navigatie te vernieuwen
### Kan ik custom CSS gebruiken?
Ja! Voeg custom CSS toe aan:
- **`/public/assets/css/style.css`** - Voor algemene styling
- **Template bestanden** - Voor specifieke componenten
- **Inline styles** - In content bestanden indien nodig
1. Upload bestanden naar de `content/` map
2. Organiseer in logische mappen
3. Gebruik juiste bestandsnamen en extensies
## Troubleshooting
### Pagina niet gevonden (404)
1. **Controleer** bestandsnaam en pad
2. **Controleer** bestandsextensie (.md, .php, .html)
3. **Controleer** permissies van bestanden
4. **Controleer** `config.json` syntax
### Template niet geladen
1. **Controleer** template bestandsnamen
2. **Controleer** template map permissies
3. **Controleer** PHP error logs
4. **Controleer** `templates_dir` configuratie
1. Controleer bestandsnaam en pad
2. Controleer bestandsextensie (.md, .php, .html)
3. Controleer permissies van bestanden
### Navigatie niet bijgewerkt
1. **Herlaad** de pagina
2. **Controleer** content map structuur
3. **Controleer** bestandsnamen (geen spaties)
4. **Controleer** PHP cache indien aanwezig
1. Herlaad de pagina
2. Controleer content map structuur
3. Controleer bestandsnamen (geen spaties)
## Ondersteuning
Voor technische ondersteuning:
- **GitHub**: https://git.noorlander.info/E.Noorlander/CodePress.git
- **Git**: https://git.noorlander.info/E.Noorlander/CodePress
- **Website**: https://noorlander.info
- **Issues**: Rapporteer problemen via GitHub issues
- **Issues**: Rapporteer problemen via [Git issues](https://git.noorlander.info/E.Noorlander/CodePress/issues)
## Licentie
CodePress CMS is open-source software. Controleer de licentie in de repository voor meer informatie.
CodePress CMS is open-source software.

View File

@@ -1,6 +1,6 @@
{
"name": "codepress",
"version": "1.0.0",
"version": "1.5.0",
"description": "A lightweight, file-based Content Management System built with PHP and Bootstrap.",
"main": "index.js",
"scripts": {

View File

@@ -0,0 +1,80 @@
<?php
class HTMLBlock
{
private array $config;
private ?CMSAPI $api = null;
public function __construct()
{
$this->config = [
'title' => 'HTML Block Plugin'
];
}
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
}
public function getSidebarContent(): string
{
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : 'Onbekend';
$isHomepage = $this->api ? $this->api->isHomepage() : false;
$currentLang = $this->api ? $this->api->getCurrentLanguage() : 'nl';
$content = '
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">' . $this->config['title'] . '</h5>
</div>
<div class="card-body">
<p class="mb-2"><strong>Huidige pagina:</strong> ' . htmlspecialchars($currentPage) . '</p>
<p class="mb-2"><strong>Taal:</strong> ' . strtoupper($currentLang) . '</p>
<p class="mb-3"><strong>Homepage:</strong> ' . ($isHomepage ? 'Ja' : 'Nee') . '</p>';
// Add page-specific content
if ($this->api) {
$fileInfo = $this->api->getCurrentPageFileInfo();
if ($fileInfo) {
$content .= '
<div class="alert alert-info mb-3">
<small>
<strong>Bestandsinfo:</strong><br>
Aangemaakt: ' . htmlspecialchars($fileInfo['created']) . '<br>
Gewijzigd: ' . htmlspecialchars($fileInfo['modified']) . '
</small>
</div>';
}
// Add quick navigation
$menu = $this->api->getMenu();
if (!empty($menu)) {
$content .= '
<h6>Quick Navigation</h6>
<ul class="list-unstyled mb-3">';
foreach ($menu as $item) {
if ($item['type'] === 'file') {
$url = $this->api->createUrl($item['path']);
$content .= '<li><a href="' . htmlspecialchars($url) . '" class="text-decoration-none">📄 ' . htmlspecialchars($item['title']) . '</a></li>';
}
}
$content .= '</ul>';
}
}
return $content;
}
public function getConfig(): array
{
return $this->config;
}
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
}

100
plugins/HTMLBlock/README.md Normal file
View File

@@ -0,0 +1,100 @@
# HTMLBlock Plugin
Deze plugin toont een custom HTML blok in de sidebar met pagina-informatie en navigatie.
## Functies
- **Pagina informatie**: Toont huidige pagina titel en metadata
- **Bestandsinfo**: Aanmaak- en wijzigingsdatums
- **Dynamische navigatie**: Genereert quick links uit het menu
- **Interactive controls**: Verversen en sidebar toggle
- **Responsive**: Werkt op desktop en mobiel
## Installatie
1. Kopieer de `HTMLBlock` map naar `plugins/`
2. De plugin wordt automatisch geladen
## Gebruik
De plugin wordt automatisch in de sidebar geladen en toont:
### Huidige Pagina Info
- Pagina titel
- Huidige taal
- Homepage status
### Bestandsinformatie
- Aanmaakdatum
- Laatste wijziging
- Bestandsgrootte
### Quick Navigation
- Dynamische links uit het CMS menu
- Automatische URL generatie
### Interactive Controls
- **Ververs Content**: Herlaadt de huidige pagina
- **Toggle Sidebar**: Toont/verbergt de sidebar
## Customization
De plugin content kan worden aangepast door de `getSidebarContent()` methode te wijzigen in `HTMLBlock.php`.
### Voorbeeld Custom Content
```php
public function getSidebarContent(): string
{
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : 'Onbekend';
return '
<div class="card">
<div class="card-body">
<h5>Mijn Custom Block</h5>
<p>Huidige pagina: ' . htmlspecialchars($currentPage) . '</p>
</div>
</div>';
}
```
## API Integration
De plugin maakt gebruik van de CMS API voor:
- `getCurrentPageTitle()` - Huidige pagina titel
- `getCurrentLanguage()` - Huidige taal
- `isHomepage()` - Check of homepage
- `getCurrentPageFileInfo()` - Bestandsinformatie
- `getMenu()` - Menu structuur
- `createUrl($page, $lang)` - URL generatie
## Styling
De plugin gebruikt Bootstrap 5 classes:
- `card`, `card-header`, `card-body` voor kaarten
- `btn`, `btn-outline-primary` voor knoppen
- `list-unstyled` voor navigatie
## JavaScript
De plugin bevat JavaScript voor:
- Pagina verversen
- Sidebar toggle functionaliteit
- Dynamische content updates
## Development
De plugin is een goed voorbeeld voor:
- API integratie
- Dynamic content generatie
- User interface components
- Responsive design
## Bestandsstructuur
```
HTMLBlock/
├── HTMLBlock.php # Hoofd plugin bestand
└── README.md # Deze documentatie
```

View File

@@ -0,0 +1,320 @@
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
// GeoIP2 will be loaded conditionally when available
class MQTTTracker
{
private ?CMSAPI $api = null;
private array $config;
private string $sessionId;
private $geoipReader = null;
public function __construct()
{
$this->loadConfig();
$this->sessionId = $this->generateSessionId();
$this->initializeGeoIP();
}
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
// Track page visit after API is available
$this->trackPageVisit();
}
private function loadConfig(): void
{
$configFile = __DIR__ . '/config.json';
$this->config = [
'enabled' => true,
'broker_host' => 'localhost',
'broker_port' => 1883,
'client_id' => 'codepress_cms',
'username' => '',
'password' => '',
'topic_prefix' => 'codepress',
'track_visitors' => true,
'track_pages' => true,
'track_performance' => true,
'track_user_flows' => true,
'session_timeout' => 1800,
'geoip_database_path' => __DIR__ . '/GeoLite2-Country.mmdb'
];
if (file_exists($configFile)) {
$jsonConfig = json_decode(file_get_contents($configFile), true);
$this->config = array_merge($this->config, $jsonConfig);
}
}
private function initializeGeoIP(): void
{
$geoipPath = $this->config['geoip_database_path'] ?? __DIR__ . '/GeoLite2-Country.mmdb';
// For now, disable GeoIP2 until properly configured
$this->logMessage('info', 'GeoIP2 temporarily disabled - will be enabled when database is available');
$this->geoipReader = null;
}
private function generateSessionId(): string
{
if (isset($_COOKIE['cms_session_id'])) {
return $_COOKIE['cms_session_id'];
}
$sessionId = uniqid('cms_', true);
setcookie('cms_session_id', $sessionId, [
'expires' => time() + $this->config['session_timeout'],
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
return $sessionId;
}
private function trackPageVisit(): void
{
if (!$this->config['enabled'] || !$this->config['track_pages']) {
return;
}
// Track user flow before updating current page
$this->trackUserFlow();
// Format URL nicely: ?page=foo/bar -> /page/foo/bar
$pageUrl = $_SERVER['REQUEST_URI'] ?? '';
if (isset($_GET['page'])) {
$pageUrl = '/page/' . $_GET['page'];
// Append other relevant params if needed, e.g., language
if (isset($_GET['lang'])) {
$pageUrl .= '?lang=' . $_GET['lang'];
}
}
$clientIp = $this->getClientIp();
$geoData = $this->getGeoData($clientIp);
$pageData = [
'timestamp' => date('c'),
'session_id' => $this->sessionId,
'page_url' => $pageUrl,
'page_title' => $this->api ? $this->api->getCurrentPageTitle() : '',
'referrer' => $_SERVER['HTTP_REFERER'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'ip_address' => $clientIp,
'country' => $geoData['country'],
'country_code' => $geoData['country_code'],
'city' => $geoData['city'],
'language' => $this->api ? $this->api->getCurrentLanguage() : 'nl',
'layout' => $this->api ? $this->getPageLayout() : 'unknown',
'device_type' => $this->getDeviceType(),
'browser' => $this->getBrowser(),
'os' => $this->getOS()
];
// Update tracking cookies
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);
}
private function getPageLayout(): string
{
if (!$this->api) return 'unknown';
$page = $this->api->getCurrentPage();
return $page['layout'] ?? 'sidebar-content';
}
private function trackUserFlow(): void
{
if (!($this->config['track_user_flows'] ?? true)) {
return;
}
$previousPage = $_COOKIE['cms_previous_page'] ?? null;
$currentPage = $this->getCurrentPageUrl();
$previousTimestamp = $_COOKIE['cms_page_timestamp'] ?? time();
if ($previousPage && $previousPage !== $currentPage) {
$flowData = [
'timestamp' => date('c'),
'session_id' => $this->sessionId,
'from_page' => $previousPage,
'to_page' => $currentPage,
'flow_duration' => time() - $previousTimestamp,
'ip_address' => $this->getClientIp()
];
$this->publishMessage('user_flow', $flowData);
}
}
private function getCurrentPageUrl(): string
{
$pageUrl = $_SERVER['REQUEST_URI'] ?? '';
if (isset($_GET['page'])) {
$pageUrl = '/page/' . $_GET['page'];
if (isset($_GET['lang'])) {
$pageUrl .= '?lang=' . $_GET['lang'];
}
}
return $pageUrl;
}
private function getGeoData(string $ip): array
{
// Simplified geolocation - will be enhanced later
return [
'country' => 'Unknown',
'country_code' => 'XX',
'city' => 'Unknown'
];
}
private function getDeviceType(): string
{
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (preg_match('/Mobile|Android|iPhone|iPad|iPod/', $userAgent)) {
return preg_match('/iPad/', $userAgent) ? 'tablet' : 'mobile';
}
return 'desktop';
}
private function getBrowser(): string
{
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (preg_match('/Chrome/', $userAgent)) return 'Chrome';
if (preg_match('/Firefox/', $userAgent)) return 'Firefox';
if (preg_match('/Safari/', $userAgent)) return 'Safari';
if (preg_match('/Edge/', $userAgent)) return 'Edge';
if (preg_match('/Opera/', $userAgent)) return 'Opera';
return 'Unknown';
}
private function getOS(): string
{
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (preg_match('/Windows/', $userAgent)) return 'Windows';
if (preg_match('/Mac/', $userAgent)) return 'macOS';
if (preg_match('/Linux/', $userAgent)) return 'Linux';
if (preg_match('/Android/', $userAgent)) return 'Android';
if (preg_match('/iOS|iPhone|iPad/', $userAgent)) return 'iOS';
return 'Unknown';
}
private function getClientIp(): string
{
// 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'];
foreach ($ipKeys as $key) {
if (!empty($_SERVER[$key])) {
$ips = explode(',', $_SERVER[$key]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
}
return $remoteAddr;
}
private function publishMessage(string $topic, array $data): void
{
if (!class_exists('PhpMqtt\Client\MqttClient')) {
$this->logMessage('error', 'MQTT client library not installed. Run: composer require php-mqtt/client');
return;
}
try {
$server = new \PhpMqtt\Client\MqttClient(
$this->config['broker_host'],
$this->config['broker_port'],
$this->config['client_id']
);
$connectionSettings = new \PhpMqtt\Client\ConnectionSettings();
if (!empty($this->config['username'])) {
$connectionSettings->setUsername($this->config['username'])
->setPassword($this->config['password']);
}
$server->connect($connectionSettings, true);
// Topic format: prefix/action
$topic = $this->config['topic_prefix'] . '/' . $topic;
$payload = json_encode($data);
$server->publish($topic, $payload, 0);
$server->disconnect();
$this->logMessage('published', $topic . ' - ' . $payload);
} catch (Exception $e) {
$this->logMessage('error', 'MQTT publish failed: ' . $e->getMessage());
}
}
private function logMessage(string $topic, string $payload): void
{
$logFile = __DIR__ . '/mqtt_tracker.log';
$logEntry = date('Y-m-d H:i:s') . " [{$topic}] {$payload}\n";
file_put_contents($logFile, $logEntry, FILE_APPEND | LOCK_EX);
}
public function getSidebarContent(): string
{
// MQTT Tracker is een functionele plugin zonder UI
return '';
}
public function getConfig(): array
{
return $this->config;
}
public function updateConfig(array $newConfig): void
{
$this->config = array_merge($this->config, $newConfig);
$configFile = __DIR__ . '/config.json';
file_put_contents($configFile, json_encode($this->config, JSON_PRETTY_PRINT));
}
}

View File

@@ -0,0 +1,83 @@
# MQTT Tracker Plugin
Deze plugin tracked pagina bezoeken en gebruikersinteracties via MQTT voor Business Intelligence en statistieken.
## Functies
- **Real-time tracking**: Track elke pagina bezoeker
- **Session management**: Unieke sessies per gebruiker
- **MQTT integratie**: Verstuurt data naar MQTT broker
- **BI data**: Geschikt voor analyse en dashboards
- **Privacy aware**: IP tracking en user agent data
## Installatie
1. Kopieer de `MQTTTracker` map naar `plugins/`
2. Configureer de MQTT broker in `config.json`
3. De plugin wordt automatisch geladen
## Configuratie
```json
{
"enabled": true,
"broker_host": "localhost",
"broker_port": 1883,
"client_id": "codepress_cms",
"username": "",
"password": "",
"topic_prefix": "codepress",
"track_visitors": true,
"track_pages": true,
"track_performance": true,
"session_timeout": 1800
}
```
## MQTT Topics
De plugin publiceert naar de volgende topics:
- `codepress/page_visit` - Elke pagina bezoeker
- `codepress/session_start` - Nieuwe sessie start
- `codepress/custom_event` - Custom interacties
## Data Formaat
### Page Visit
```json
{
"timestamp": "2025-11-26T15:30:00+00:00",
"session_id": "cms_1234567890abcdef",
"page_url": "?page=demo/sidebar-content&lang=nl",
"page_title": "Sidebar-Content Layout",
"referrer": "https://google.com",
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.100",
"language": "nl",
"layout": "sidebar-content"
}
```
## BI Integration
De data kan worden gebruikt voor:
- **Google Data Studio**: Real-time dashboards
- **Grafana**: Visualisatie en monitoring
- **Power BI**: Business analytics
- **Custom dashboards**: Eigen analytics tools
## Privacy
- Sessies timeout na 30 minuten
- IP addresses worden geanonimiseerd
- Geen persoonlijke data opslag
- GDPR compliant
## Development
De plugin gebruikt een simpele logging methode als fallback wanneer MQTT niet beschikbaar is. Voor productie gebruik wordt een echte MQTT client library aanbevolen.
## Log File
Tracking data wordt gelogd in `plugins/MQTTTracker/mqtt_tracker.log` voor debugging en fallback.

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"broker_host": "mqtt.prive.noorlander.info",
"broker_port": 1883,
"client_id": "codepress_cms",
"username": "",
"password": "",
"topic_prefix": "codepress",
"track_visitors": true,
"track_pages": true,
"track_performance": true,
"track_user_flows": true,
"session_timeout": 1800,
"geoip_database_path": "/plugins/MQTTTracker/GeoLite2-Country.mmdb"
}

102
plugins/README.md Normal file
View File

@@ -0,0 +1,102 @@
# CodePress CMS Plugins
Deze map bevat plugins voor de CodePress CMS. Elke plugin heeft zijn eigen submap met de plugin code.
## Plugin Structuur
Elke plugin map moet het volgende bevatten:
```
PluginName/
├── PluginName.php # Hoofd plugin bestand
├── README.md # Plugin documentatie (optioneel)
├── config.json # Plugin configuratie (optioneel)
└── assets/ # CSS, JS, images (optioneel)
├── css/
├── js/
└── images/
```
## Beschikbare Plugins
### HTMLBlock
Toont een custom HTML blok in de sidebar met pagina-informatie en navigatie.
**Locatie:** `HTMLBlock/HTMLBlock.php`
**Functies:**
- Toont huidige pagina informatie
- Dynamische navigatie
- Bestandsinformatie
- Interactive controls
## Plugin Development
### Basis Plugin Class
```php
<?php
class MyPlugin
{
private ?CMSAPI $api = null;
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
}
public function getSidebarContent(): string
{
return '<div>Mijn plugin content</div>';
}
}
```
### Beschikbare API Methodes
- `getCurrentPage()` - Huidige pagina data
- `getCurrentPageTitle()` - Huidige pagina titel
- `getMenu()` - Menu structuur
- `getConfig($key)` - Configuratie waardes
- `translate($key)` - Vertalingen
- `getCurrentLanguage()` - Huidige taal
- `isHomepage()` - Check of homepage
- `getCurrentPageFileInfo()` - Bestandsinformatie
- `createUrl($page, $lang)` - URL generatie
### Plugin Hooks
Plugins kunnen de volgende methodes implementeren:
- `getSidebarContent()` - Content voor sidebar
- `setAPI(CMSAPI $api)` - API injectie
## Configuratie
Plugins kunnen een `config.json` bestand hebben:
```json
{
"enabled": true,
"settings": {
"option1": "value1",
"option2": "value2"
}
}
```
## Installatie
1. Maak een nieuwe map in `plugins/`
2. Plaats de plugin class in `PluginName/PluginName.php`
3. Optioneel: voeg README.md en config.json toe
4. De plugin wordt automatisch geladen door de CMS
## Best Practices
- Gebruik `htmlspecialchars()` voor output
- Implementeer `setAPI()` voor CMS toegang
- Volg PSR-12 coding standards
- Gebruik namespace indien nodig
- Documenteer je plugin met README.md

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

View File

@@ -1,41 +1,94 @@
// 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 initialized');
// Handle nested dropdowns for touch devices
const dropdownSubmenus = document.querySelectorAll('.dropdown-submenu');
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) {
e.preventDefault();
e.stopPropagation();
// 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');
}
});
// 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');
// 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');
});
});
});
});

View File

@@ -7,8 +7,11 @@ $baseDir = dirname($vendorDir);
return array(
'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'),
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
'Psr\\EventDispatcher\\' => array($vendorDir . '/psr/event-dispatcher/src'),
'PhpMqtt\\Client\\' => array($vendorDir . '/php-mqtt/client/src'),
'Nette\\' => array($vendorDir . '/nette/schema/src', $vendorDir . '/nette/utils/src'),
'MyCLabs\\Enum\\' => array($vendorDir . '/myclabs/php-enum/src'),
'Mustache\\' => array($vendorDir . '/mustache/mustache/src'),
'League\\Config\\' => array($vendorDir . '/league/config/src'),
'League\\CommonMark\\' => array($vendorDir . '/league/commonmark/src'),

View File

@@ -18,7 +18,9 @@ class ComposerStaticInit071586d19f5409de22b3235d85d8476c
),
'P' =>
array (
'Psr\\Log\\' => 8,
'Psr\\EventDispatcher\\' => 20,
'PhpMqtt\\Client\\' => 15,
),
'N' =>
array (
@@ -26,6 +28,7 @@ class ComposerStaticInit071586d19f5409de22b3235d85d8476c
),
'M' =>
array (
'MyCLabs\\Enum\\' => 13,
'Mustache\\' => 9,
),
'L' =>
@@ -44,15 +47,27 @@ class ComposerStaticInit071586d19f5409de22b3235d85d8476c
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-php80',
),
'Psr\\Log\\' =>
array (
0 => __DIR__ . '/..' . '/psr/log/src',
),
'Psr\\EventDispatcher\\' =>
array (
0 => __DIR__ . '/..' . '/psr/event-dispatcher/src',
),
'PhpMqtt\\Client\\' =>
array (
0 => __DIR__ . '/..' . '/php-mqtt/client/src',
),
'Nette\\' =>
array (
0 => __DIR__ . '/..' . '/nette/schema/src',
1 => __DIR__ . '/..' . '/nette/utils/src',
),
'MyCLabs\\Enum\\' =>
array (
0 => __DIR__ . '/..' . '/myclabs/php-enum/src',
),
'Mustache\\' =>
array (
0 => __DIR__ . '/..' . '/mustache/mustache/src',

View File

@@ -80,17 +80,17 @@
},
{
"name": "league/commonmark",
"version": "2.7.1",
"version_normalized": "2.7.1.0",
"version": "2.8.0",
"version_normalized": "2.8.0.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
"reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
"shasum": ""
},
"require": {
@@ -124,11 +124,11 @@
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
},
"time": "2025-07-20T12:47:49+00:00",
"time": "2025-11-26T21:48:24+00:00",
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.8-dev"
"dev-main": "2.9-dev"
}
},
"installation-source": "dist",
@@ -329,6 +329,72 @@
},
"install-path": "../mustache/mustache"
},
{
"name": "myclabs/php-enum",
"version": "1.8.5",
"version_normalized": "1.8.5.0",
"source": {
"type": "git",
"url": "https://github.com/myclabs/php-enum.git",
"reference": "e7be26966b7398204a234f8673fdad5ac6277802"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802",
"reference": "e7be26966b7398204a234f8673fdad5ac6277802",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.3 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "1.*",
"vimeo/psalm": "^4.6.2 || ^5.2"
},
"time": "2025-01-14T11:49:03+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"MyCLabs\\Enum\\": "src/"
},
"classmap": [
"stubs/Stringable.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP Enum contributors",
"homepage": "https://github.com/myclabs/php-enum/graphs/contributors"
}
],
"description": "PHP Enum implementation",
"homepage": "https://github.com/myclabs/php-enum",
"keywords": [
"enum"
],
"support": {
"issues": "https://github.com/myclabs/php-enum/issues",
"source": "https://github.com/myclabs/php-enum/tree/1.8.5"
},
"funding": [
{
"url": "https://github.com/mnapoli",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum",
"type": "tidelift"
}
],
"install-path": "../myclabs/php-enum"
},
{
"name": "nette/schema",
"version": "v1.3.3",
@@ -399,17 +465,17 @@
},
{
"name": "nette/utils",
"version": "v4.0.8",
"version_normalized": "4.0.8.0",
"version": "v4.0.9",
"version_normalized": "4.0.9.0",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede"
"reference": "505a30ad386daa5211f08a318e47015b501cad30"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede",
"reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede",
"url": "https://api.github.com/repos/nette/utils/zipball/505a30ad386daa5211f08a318e47015b501cad30",
"reference": "505a30ad386daa5211f08a318e47015b501cad30",
"shasum": ""
},
"require": {
@@ -433,7 +499,7 @@
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"time": "2025-08-06T21:43:34+00:00",
"time": "2025-10-31T00:45:47+00:00",
"type": "library",
"extra": {
"branch-alias": {
@@ -485,10 +551,70 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.0.8"
"source": "https://github.com/nette/utils/tree/v4.0.9"
},
"install-path": "../nette/utils"
},
{
"name": "php-mqtt/client",
"version": "v2.3.0",
"version_normalized": "2.3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-mqtt/client.git",
"reference": "3d141846753a0adee265680ae073cfb9030f2390"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-mqtt/client/zipball/3d141846753a0adee265680ae073cfb9030f2390",
"reference": "3d141846753a0adee265680ae073cfb9030f2390",
"shasum": ""
},
"require": {
"myclabs/php-enum": "^1.7",
"php": "^8.0",
"psr/log": "^1.1|^2.0|^3.0"
},
"require-dev": {
"phpunit/php-invoker": "^3.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "^3.5"
},
"suggest": {
"ext-redis": "Required for the RedisRepository"
},
"time": "2025-09-30T17:53:34+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"PhpMqtt\\Client\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Marvin Mall",
"email": "marvin-mall@msn.com",
"role": "developer"
}
],
"description": "An MQTT client written in and for PHP.",
"keywords": [
"client",
"mqtt",
"publish",
"subscribe"
],
"support": {
"issues": "https://github.com/php-mqtt/client/issues",
"source": "https://github.com/php-mqtt/client/tree/v2.3.0"
},
"install-path": "../php-mqtt/client"
},
{
"name": "psr/event-dispatcher",
"version": "1.0.0",
@@ -542,6 +668,59 @@
},
"install-path": "../psr/event-dispatcher"
},
{
"name": "psr/log",
"version": "3.0.2",
"version_normalized": "3.0.2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"time": "2024-09-11T13:17:53+00:00",
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"installation-source": "dist",
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"install-path": "../psr/log"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",

View File

@@ -3,7 +3,7 @@
'name' => '__root__',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'dfe2df141ba6e64e5425699cd553fb9b7e6d6193',
'reference' => 'f685c2490ab18e80afa64695135087eeeec57804',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -13,7 +13,7 @@
'__root__' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'dfe2df141ba6e64e5425699cd553fb9b7e6d6193',
'reference' => 'f685c2490ab18e80afa64695135087eeeec57804',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -29,9 +29,9 @@
'dev_requirement' => false,
),
'league/commonmark' => array(
'pretty_version' => '2.7.1',
'version' => '2.7.1.0',
'reference' => '10732241927d3971d28e7ea7b5712721fa2296ca',
'pretty_version' => '2.8.0',
'version' => '2.8.0.0',
'reference' => '4efa10c1e56488e658d10adf7b7b7dcd19940bfb',
'type' => 'library',
'install_path' => __DIR__ . '/../league/commonmark',
'aliases' => array(),
@@ -55,6 +55,15 @@
'aliases' => array(),
'dev_requirement' => false,
),
'myclabs/php-enum' => array(
'pretty_version' => '1.8.5',
'version' => '1.8.5.0',
'reference' => 'e7be26966b7398204a234f8673fdad5ac6277802',
'type' => 'library',
'install_path' => __DIR__ . '/../myclabs/php-enum',
'aliases' => array(),
'dev_requirement' => false,
),
'nette/schema' => array(
'pretty_version' => 'v1.3.3',
'version' => '1.3.3.0',
@@ -65,14 +74,23 @@
'dev_requirement' => false,
),
'nette/utils' => array(
'pretty_version' => 'v4.0.8',
'version' => '4.0.8.0',
'reference' => 'c930ca4e3cf4f17dcfb03037703679d2396d2ede',
'pretty_version' => 'v4.0.9',
'version' => '4.0.9.0',
'reference' => '505a30ad386daa5211f08a318e47015b501cad30',
'type' => 'library',
'install_path' => __DIR__ . '/../nette/utils',
'aliases' => array(),
'dev_requirement' => false,
),
'php-mqtt/client' => array(
'pretty_version' => 'v2.3.0',
'version' => '2.3.0.0',
'reference' => '3d141846753a0adee265680ae073cfb9030f2390',
'type' => 'library',
'install_path' => __DIR__ . '/../php-mqtt/client',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/event-dispatcher' => array(
'pretty_version' => '1.0.0',
'version' => '1.0.0.0',
@@ -82,6 +100,15 @@
'aliases' => array(),
'dev_requirement' => false,
),
'psr/log' => array(
'pretty_version' => '3.0.2',
'version' => '3.0.2.0',
'reference' => 'f16e1d5863e37f8d8c2a01719f5b34baa2b714d3',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/log',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.6.0',
'version' => '3.6.0.0',

View File

@@ -6,6 +6,14 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
## [Unreleased][unreleased]
## [2.8.0] - 2025-11-26
### Added
- Added a new `HighlightExtension` for marking important text using `==` syntax (#1100)
### Fixed
- Fixed `AutolinkExtension` incorrectly matching URLs after invalid `www.` prefix (#1095, #1103)
## [2.7.1] - 2025-07-20
### Changed
@@ -709,7 +717,8 @@ No changes were introduced since the previous release.
- Alternative 1: Use `CommonMarkConverter` or `GithubFlavoredMarkdownConverter` if you don't need to customize the environment
- Alternative 2: Instantiate a new `Environment` and add the necessary extensions yourself
[unreleased]: https://github.com/thephpleague/commonmark/compare/2.7.1...HEAD
[unreleased]: https://github.com/thephpleague/commonmark/compare/2.8.0...HEAD
[2.8.0]: https://github.com/thephpleague/commonmark/compare/2.7.1...2.8.0
[2.7.1]: https://github.com/thephpleague/commonmark/compare/2.7.0...2.7.1
[2.7.0]: https://github.com/thephpleague/commonmark/compare/2.6.2...2.7.0
[2.6.2]: https://github.com/thephpleague/commonmark/compare/2.6.1...2.6.2

View File

@@ -100,11 +100,12 @@ See [our extension documentation](https://commonmark.thephpleague.com/extensions
Custom parsers/renderers can be bundled into extensions which extend CommonMark. Here are some that you may find interesting:
- [Emoji extension](https://github.com/ElGigi/CommonMarkEmoji) - UTF-8 emoji extension with Github tag.
- [Sup Sub extensions](https://github.com/OWS/commonmark-sup-sub-extensions) - Adds support of superscript and subscript (`<sup>` and `<sub>` HTML tags)
- [Sup Sub extensions](https://github.com/OWS/commonmark-sup-sub-extensions) - Adds support of superscript and subscript (`<sup>` and `<sub>` HTML tags).
- [YouTube iframe extension](https://github.com/zoonru/commonmark-ext-youtube-iframe) - Replaces youtube link with iframe.
- [Lazy Image extension](https://github.com/simonvomeyser/commonmark-ext-lazy-image) - Adds various options for lazy loading of images.
- [Marker Extension](https://github.com/noah1400/commonmark-marker-extension) - Adds support of highlighted text (`<mark>` HTML tag)
- [Pygments Highlighter extension](https://github.com/DanielEScherzer/commonmark-ext-pygments-highlighter) - Adds support for highlighting code with the Pygments library
- [Marker Extension](https://github.com/noah1400/commonmark-marker-extension) - Adds support of highlighted text (`<mark>` HTML tag).
- [Pygments Highlighter extension](https://github.com/DanielEScherzer/commonmark-ext-pygments-highlighter) - Adds support for highlighting code with the Pygments library.
- [LatexRenderer extension](https://github.com/samwilson/commonmark-latex) - For rendering Markdown to LaTeX.
Others can be found on [Packagist under the `commonmark-extension` package type](https://packagist.org/packages/league/commonmark?type=commonmark-extension).

View File

@@ -116,7 +116,7 @@
},
"extra": {
"branch-alias": {
"dev-main": "2.8-dev"
"dev-main": "2.9-dev"
}
},
"config": {

View File

@@ -23,7 +23,7 @@ final class UrlAutolinkParser implements InlineParserInterface
private const ALLOWED_AFTER = [null, ' ', "\t", "\n", "\x0b", "\x0c", "\x0d", '*', '_', '~', '('];
// RegEx adapted from https://github.com/symfony/symfony/blob/6.3/src/Symfony/Component/Validator/Constraints/UrlValidator.php
private const REGEX = '~
private const REGEX = '~^
(
# Must start with a supported scheme + auth, or "www"
(?:

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Extension\Highlight;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
class HighlightExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addDelimiterProcessor(new MarkDelimiterProcessor());
$environment->addRenderer(Mark::class, new MarkRenderer());
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Extension\Highlight;
use League\CommonMark\Node\Inline\AbstractInline;
use League\CommonMark\Node\Inline\DelimitedInterface;
final class Mark extends AbstractInline implements DelimitedInterface
{
private string $delimiter;
public function __construct(string $delimiter = '==')
{
parent::__construct();
$this->delimiter = $delimiter;
}
public function getOpeningDelimiter(): string
{
return $this->delimiter;
}
public function getClosingDelimiter(): string
{
return $this->delimiter;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Extension\Highlight;
use League\CommonMark\Delimiter\DelimiterInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
use League\CommonMark\Node\Inline\AbstractStringContainer;
class MarkDelimiterProcessor implements DelimiterProcessorInterface
{
public function getOpeningCharacter(): string
{
return '=';
}
public function getClosingCharacter(): string
{
return '=';
}
public function getMinLength(): int
{
return 2;
}
public function getDelimiterUse(DelimiterInterface $opener, DelimiterInterface $closer): int
{
if ($opener->getLength() > 2 && $closer->getLength() > 2) {
return 0;
}
if ($opener->getLength() !== $closer->getLength()) {
return 0;
}
// $opener and $closer are the same length so we just return one of them
return $opener->getLength();
}
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void
{
$mark = new Mark(\str_repeat('=', $delimiterUse));
$next = $opener->next();
while ($next !== null && $next !== $closer) {
$tmp = $next->next();
$mark->appendChild($next);
$next = $tmp;
}
$opener->insertAfter($mark);
}
public function getCacheKey(DelimiterInterface $closer): string
{
return '=' . $closer->getLength();
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Extension\Highlight;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
use League\CommonMark\Xml\XmlNodeRendererInterface;
final class MarkRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
/**
* @param Mark $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
Mark::assertInstanceOf($node);
return new HtmlElement('mark', $node->data->get('attributes'), $childRenderer->renderNodes($node->children()));
}
public function getXmlTagName(Node $node): string
{
return 'mark';
}
/**
* {@inheritDoc}
*/
public function getXmlAttributes(Node $node): array
{
return [];
}
}

18
vendor/myclabs/php-enum/LICENSE vendored Normal file
View File

@@ -0,0 +1,18 @@
The MIT License (MIT)
Copyright (c) 2015 My C-Labs
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

196
vendor/myclabs/php-enum/README.md vendored Normal file
View File

@@ -0,0 +1,196 @@
# PHP Enum implementation inspired from SplEnum
[![GitHub Actions][GA Image]][GA Link]
[![Latest Stable Version](https://poser.pugx.org/myclabs/php-enum/version.png)](https://packagist.org/packages/myclabs/php-enum)
[![Total Downloads](https://poser.pugx.org/myclabs/php-enum/downloads.png)](https://packagist.org/packages/myclabs/php-enum)
[![Psalm Shepherd][Shepherd Image]][Shepherd Link]
Maintenance for this project is [supported via Tidelift](https://tidelift.com/subscription/pkg/packagist-myclabs-php-enum?utm_source=packagist-myclabs-php-enum&utm_medium=referral&utm_campaign=readme).
## Why?
First, and mainly, `SplEnum` is not integrated to PHP, you have to install the extension separately.
Using an enum instead of class constants provides the following advantages:
- You can use an enum as a parameter type: `function setAction(Action $action) {`
- You can use an enum as a return type: `function getAction() : Action {`
- You can enrich the enum with methods (e.g. `format`, `parse`, …)
- You can extend the enum to add new values (make your enum `final` to prevent it)
- You can get a list of all the possible values (see below)
This Enum class is not intended to replace class constants, but only to be used when it makes sense.
## Installation
```
composer require myclabs/php-enum
```
## Declaration
```php
use MyCLabs\Enum\Enum;
/**
* Action enum
*
* @extends Enum<Action::*>
*/
final class Action extends Enum
{
private const VIEW = 'view';
private const EDIT = 'edit';
}
```
## Usage
```php
$action = Action::VIEW();
// or with a dynamic key:
$action = Action::$key();
// or with a dynamic value:
$action = Action::from($value);
// or
$action = new Action($value);
```
As you can see, static methods are automatically implemented to provide quick access to an enum value.
One advantage over using class constants is to be able to use an enum as a parameter type:
```php
function setAction(Action $action) {
// ...
}
```
## Documentation
- `__construct()` The constructor checks that the value exist in the enum
- `__toString()` You can `echo $myValue`, it will display the enum value (value of the constant)
- `getValue()` Returns the current value of the enum
- `getKey()` Returns the key of the current value on Enum
- `equals()` Tests whether enum instances are equal (returns `true` if enum values are equal, `false` otherwise)
Static methods:
- `from()` Creates an Enum instance, checking that the value exist in the enum
- `toArray()` method Returns all possible values as an array (constant name in key, constant value in value)
- `keys()` Returns the names (keys) of all constants in the Enum class
- `values()` Returns instances of the Enum class of all Enum constants (constant name in key, Enum instance in value)
- `isValid()` Check if tested value is valid on enum set
- `isValidKey()` Check if tested key is valid on enum set
- `assertValidValue()` Assert the value is valid on enum set, throwing exception otherwise
- `search()` Return key for searched value
### Static methods
```php
final class Action extends Enum
{
private const VIEW = 'view';
private const EDIT = 'edit';
}
// Static method:
$action = Action::VIEW();
$action = Action::EDIT();
```
Static method helpers are implemented using [`__callStatic()`](http://www.php.net/manual/en/language.oop5.overloading.php#object.callstatic).
If you care about IDE autocompletion, you can either implement the static methods yourself:
```php
final class Action extends Enum
{
private const VIEW = 'view';
/**
* @return Action
*/
public static function VIEW() {
return new Action(self::VIEW);
}
}
```
or you can use phpdoc (this is supported in PhpStorm for example):
```php
/**
* @method static Action VIEW()
* @method static Action EDIT()
*/
final class Action extends Enum
{
private const VIEW = 'view';
private const EDIT = 'edit';
}
```
## Native enums and migration
Native enum arrived to PHP in version 8.1: https://www.php.net/enumerations
If your project is running PHP 8.1+ or your library has it as a minimum requirement you should use it instead of this library.
When migrating from `myclabs/php-enum`, the effort should be small if the usage was in the recommended way:
- private constants
- final classes
- no method overridden
Changes for migration:
- Class definition should be changed from
```php
/**
* @method static Action VIEW()
* @method static Action EDIT()
*/
final class Action extends Enum
{
private const VIEW = 'view';
private const EDIT = 'edit';
}
```
to
```php
enum Action: string
{
case VIEW = 'view';
case EDIT = 'edit';
}
```
All places where the class was used as a type will continue to work.
Usages and the change needed:
| Operation | myclabs/php-enum | native enum |
|----------------------------------------------------------------|----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Obtain an instance will change from | `$enumCase = Action::VIEW()` | `$enumCase = Action::VIEW` |
| Create an enum from a backed value | `$enumCase = new Action('view')` | `$enumCase = Action::from('view')` |
| Get the backed value of the enum instance | `$enumCase->getValue()` | `$enumCase->value` |
| Compare two enum instances | `$enumCase1 == $enumCase2` <br/> or <br/> `$enumCase1->equals($enumCase2)` | `$enumCase1 === $enumCase2` |
| Get the key/name of the enum instance | `$enumCase->getKey()` | `$enumCase->name` |
| Get a list of all the possible instances of the enum | `Action::values()` | `Action::cases()` |
| Get a map of possible instances of the enum mapped by name | `Action::values()` | `array_combine(array_map(fn($case) => $case->name, Action::cases()), Action::cases())` <br/> or <br/> `(new ReflectionEnum(Action::class))->getConstants()` |
| Get a list of all possible names of the enum | `Action::keys()` | `array_map(fn($case) => $case->name, Action::cases())` |
| Get a list of all possible backed values of the enum | `Action::toArray()` | `array_map(fn($case) => $case->value, Action::cases())` |
| Get a map of possible backed values of the enum mapped by name | `Action::toArray()` | `array_combine(array_map(fn($case) => $case->name, Action::cases()), array_map(fn($case) => $case->value, Action::cases()))` <br/> or <br/> `array_map(fn($case) => $case->value, (new ReflectionEnum(Action::class))->getConstants()))` |
## Related projects
- [PHP 8.1+ native enum](https://www.php.net/enumerations)
- [Doctrine enum mapping](https://github.com/acelaya/doctrine-enum-type)
- [Symfony ParamConverter integration](https://github.com/Ex3v/MyCLabsEnumParamConverter)
- [PHPStan integration](https://github.com/timeweb/phpstan-enum)
[GA Image]: https://github.com/myclabs/php-enum/workflows/CI/badge.svg
[GA Link]: https://github.com/myclabs/php-enum/actions?query=workflow%3A%22CI%22+branch%3Amaster
[Shepherd Image]: https://shepherd.dev/github/myclabs/php-enum/coverage.svg
[Shepherd Link]: https://shepherd.dev/github/myclabs/php-enum

11
vendor/myclabs/php-enum/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
Only the latest stable release is supported.
## Reporting a Vulnerability
To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security).
Tidelift will coordinate the fix and disclosure.

36
vendor/myclabs/php-enum/composer.json vendored Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "myclabs/php-enum",
"type": "library",
"description": "PHP Enum implementation",
"keywords": ["enum"],
"homepage": "https://github.com/myclabs/php-enum",
"license": "MIT",
"authors": [
{
"name": "PHP Enum contributors",
"homepage": "https://github.com/myclabs/php-enum/graphs/contributors"
}
],
"autoload": {
"psr-4": {
"MyCLabs\\Enum\\": "src/"
},
"classmap": [
"stubs/Stringable.php"
]
},
"autoload-dev": {
"psr-4": {
"MyCLabs\\Tests\\Enum\\": "tests/"
}
},
"require": {
"php": "^7.3 || ^8.0",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "1.*",
"vimeo/psalm": "^4.6.2 || ^5.2"
}
}

319
vendor/myclabs/php-enum/src/Enum.php vendored Normal file
View File

@@ -0,0 +1,319 @@
<?php
/**
* @link http://github.com/myclabs/php-enum
* @license http://www.opensource.org/licenses/mit-license.php MIT (see the LICENSE file)
*/
namespace MyCLabs\Enum;
/**
* Base Enum class
*
* Create an enum by implementing this class and adding class constants.
*
* @author Matthieu Napoli <matthieu@mnapoli.fr>
* @author Daniel Costa <danielcosta@gmail.com>
* @author Mirosław Filip <mirfilip@gmail.com>
*
* @psalm-template T
* @psalm-immutable
* @psalm-consistent-constructor
*/
abstract class Enum implements \JsonSerializable, \Stringable
{
/**
* Enum value
*
* @var mixed
* @psalm-var T
*/
protected $value;
/**
* Enum key, the constant name
*
* @var string
*/
private $key;
/**
* Store existing constants in a static cache per object.
*
*
* @var array
* @psalm-var array<class-string, array<string, mixed>>
*/
protected static $cache = [];
/**
* Cache of instances of the Enum class
*
* @var array
* @psalm-var array<class-string, array<string, static>>
*/
protected static $instances = [];
/**
* Creates a new value of some type
*
* @psalm-pure
* @param mixed $value
*
* @psalm-param T $value
* @throws \UnexpectedValueException if incompatible type is given.
*/
public function __construct($value)
{
if ($value instanceof static) {
/** @psalm-var T */
$value = $value->getValue();
}
/** @psalm-suppress ImplicitToStringCast assertValidValueReturningKey returns always a string but psalm has currently an issue here */
$this->key = static::assertValidValueReturningKey($value);
/** @psalm-var T */
$this->value = $value;
}
/**
* This method exists only for the compatibility reason when deserializing a previously serialized version
* that didn't had the key property
*/
public function __wakeup()
{
/** @psalm-suppress DocblockTypeContradiction key can be null when deserializing an enum without the key */
if ($this->key === null) {
/**
* @psalm-suppress InaccessibleProperty key is not readonly as marked by psalm
* @psalm-suppress PossiblyFalsePropertyAssignmentValue deserializing a case that was removed
*/
$this->key = static::search($this->value);
}
}
/**
* @param mixed $value
* @return static
*/
public static function from($value): self
{
$key = static::assertValidValueReturningKey($value);
return self::__callStatic($key, []);
}
/**
* @psalm-pure
* @return mixed
* @psalm-return T
*/
public function getValue()
{
return $this->value;
}
/**
* Returns the enum key (i.e. the constant name).
*
* @psalm-pure
* @return string
*/
public function getKey()
{
return $this->key;
}
/**
* @psalm-pure
* @psalm-suppress InvalidCast
* @return string
*/
public function __toString()
{
return (string)$this->value;
}
/**
* Determines if Enum should be considered equal with the variable passed as a parameter.
* Returns false if an argument is an object of different class or not an object.
*
* This method is final, for more information read https://github.com/myclabs/php-enum/issues/4
*
* @psalm-pure
* @psalm-param mixed $variable
* @return bool
*/
final public function equals($variable = null): bool
{
return $variable instanceof self
&& $this->getValue() === $variable->getValue()
&& static::class === \get_class($variable);
}
/**
* Returns the names (keys) of all constants in the Enum class
*
* @psalm-pure
* @psalm-return list<string>
* @return array
*/
public static function keys()
{
return \array_keys(static::toArray());
}
/**
* Returns instances of the Enum class of all Enum constants
*
* @psalm-pure
* @psalm-return array<string, static>
* @return static[] Constant name in key, Enum instance in value
*/
public static function values()
{
$values = array();
/** @psalm-var T $value */
foreach (static::toArray() as $key => $value) {
/** @psalm-suppress UnsafeGenericInstantiation */
$values[$key] = new static($value);
}
return $values;
}
/**
* Returns all possible values as an array
*
* @psalm-pure
* @psalm-suppress ImpureStaticProperty
*
* @psalm-return array<string, mixed>
* @return array Constant name in key, constant value in value
*/
public static function toArray()
{
$class = static::class;
if (!isset(static::$cache[$class])) {
/** @psalm-suppress ImpureMethodCall this reflection API usage has no side-effects here */
$reflection = new \ReflectionClass($class);
/** @psalm-suppress ImpureMethodCall this reflection API usage has no side-effects here */
static::$cache[$class] = $reflection->getConstants();
}
return static::$cache[$class];
}
/**
* Check if is valid enum value
*
* @param $value
* @psalm-param mixed $value
* @psalm-pure
* @psalm-assert-if-true T $value
* @return bool
*/
public static function isValid($value)
{
return \in_array($value, static::toArray(), true);
}
/**
* Asserts valid enum value
*
* @psalm-pure
* @psalm-assert T $value
* @param mixed $value
*/
public static function assertValidValue($value): void
{
self::assertValidValueReturningKey($value);
}
/**
* Asserts valid enum value
*
* @psalm-pure
* @psalm-assert T $value
* @param mixed $value
* @return string
*/
private static function assertValidValueReturningKey($value): string
{
if (false === ($key = static::search($value))) {
throw new \UnexpectedValueException("Value '$value' is not part of the enum " . static::class);
}
return $key;
}
/**
* Check if is valid enum key
*
* @param $key
* @psalm-param string $key
* @psalm-pure
* @return bool
*/
public static function isValidKey($key)
{
$array = static::toArray();
return isset($array[$key]) || \array_key_exists($key, $array);
}
/**
* Return key for value
*
* @param mixed $value
*
* @psalm-param mixed $value
* @psalm-pure
* @return string|false
*/
public static function search($value)
{
return \array_search($value, static::toArray(), true);
}
/**
* Returns a value when called statically like so: MyEnum::SOME_VALUE() given SOME_VALUE is a class constant
*
* @param string $name
* @param array $arguments
*
* @return static
* @throws \BadMethodCallException
*
* @psalm-pure
*/
public static function __callStatic($name, $arguments)
{
$class = static::class;
if (!isset(self::$instances[$class][$name])) {
$array = static::toArray();
if (!isset($array[$name]) && !\array_key_exists($name, $array)) {
$message = "No static method or enum constant '$name' in class " . static::class;
throw new \BadMethodCallException($message);
}
/** @psalm-suppress UnsafeGenericInstantiation */
return self::$instances[$class][$name] = new static($array[$name]);
}
return clone self::$instances[$class][$name];
}
/**
* Specify data which should be serialized to JSON. This method returns data that can be serialized by json_encode()
* natively.
*
* @return mixed
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->getValue();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace MyCLabs\Enum\PHPUnit;
use MyCLabs\Enum\Enum;
use SebastianBergmann\Comparator\ComparisonFailure;
/**
* Use this Comparator to get nice output when using PHPUnit assertEquals() with Enums.
*
* Add this to your PHPUnit bootstrap PHP file:
*
* \SebastianBergmann\Comparator\Factory::getInstance()->register(new \MyCLabs\Enum\PHPUnit\Comparator());
*/
final class Comparator extends \SebastianBergmann\Comparator\Comparator
{
public function accepts($expected, $actual)
{
return $expected instanceof Enum && (
$actual instanceof Enum || $actual === null
);
}
/**
* @param Enum $expected
* @param Enum|null $actual
*
* @return void
*/
public function assertEquals($expected, $actual, $delta = 0.0, $canonicalize = false, $ignoreCase = false)
{
if ($expected->equals($actual)) {
return;
}
throw new ComparisonFailure(
$expected,
$actual,
$this->formatEnum($expected),
$this->formatEnum($actual),
false,
'Failed asserting that two Enums are equal.'
);
}
private function formatEnum(?Enum $enum = null)
{
if ($enum === null) {
return "null";
}
return get_class($enum)."::{$enum->getKey()}()";
}
}

View File

@@ -0,0 +1,11 @@
<?php
if (\PHP_VERSION_ID < 80000 && !interface_exists('Stringable')) {
interface Stringable
{
/**
* @return string
*/
public function __toString();
}
}

View File

@@ -34,7 +34,7 @@ class CachingIterator extends \CachingIterator implements \Countable
public function __construct(iterable|\stdClass $iterable)
{
$iterable = $iterable instanceof \stdClass
? new \ArrayIterator($iterable)
? new \ArrayIterator((array) $iterable)
: Nette\Utils\Iterables::toIterator($iterable);
parent::__construct($iterable, 0);
}

View File

@@ -533,7 +533,7 @@ class Arrays
*/
public static function toKey(mixed $value): int|string
{
return key([$value => null]);
return key(@[$value => null]);
}

View File

@@ -239,8 +239,8 @@ class Image
*/
public static function detectTypeFromFile(string $file, &$width = null, &$height = null): ?int
{
[$width, $height, $type] = @getimagesize($file); // @ - files smaller than 12 bytes causes read error
return isset(self::Formats[$type]) ? $type : null;
[$width, $height, $type] = Helpers::falseToNull(@getimagesize($file)); // @ - files smaller than 12 bytes causes read error
return $type && isset(self::Formats[$type]) ? $type : null;
}
@@ -250,8 +250,8 @@ class Image
*/
public static function detectTypeFromString(string $s, &$width = null, &$height = null): ?int
{
[$width, $height, $type] = @getimagesizefromstring($s); // @ - strings smaller than 12 bytes causes read error
return isset(self::Formats[$type]) ? $type : null;
[$width, $height, $type] = Helpers::falseToNull(@getimagesizefromstring($s)); // @ - strings smaller than 12 bytes causes read error
return $type && isset(self::Formats[$type]) ? $type : null;
}

2503
vendor/php-mqtt/client/.ci/emqx.conf vendored Normal file

File diff suppressed because it is too large Load Diff

56
vendor/php-mqtt/client/.ci/hivemq.xml vendored Normal file
View File

@@ -0,0 +1,56 @@
<?xml version="1.0"?>
<hivemq xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/peez80/docker-hivemq/master/hivemq-config.xsd"
>
<listeners>
<!-- MQTT port without TLS -->
<tcp-listener>
<port>1883</port>
<bind-address>0.0.0.0</bind-address>
</tcp-listener>
<!-- MQTT port with TLS but without client certificate validation -->
<tls-tcp-listener>
<port>8883</port>
<bind-address>0.0.0.0</bind-address>
<tls>
<keystore>
<path>/hivemq-certs/server.jks</path>
<password>s3cr3t</password>
<private-key-password>s3cr3t</private-key-password>
</keystore>
<protocols>
<protocol>TLSv1.3</protocol>
<protocol>TLSv1.2</protocol>
<protocol>TLSv1.1</protocol>
<protocol>TLSv1</protocol>
</protocols>
</tls>
</tls-tcp-listener>
<!-- MQTT port with TLS and with client certificate validation -->
<tls-tcp-listener>
<port>8884</port>
<bind-address>0.0.0.0</bind-address>
<tls>
<client-authentication-mode>REQUIRED</client-authentication-mode>
<truststore>
<path>/hivemq-certs/ca.jks</path>
<password>s3cr3t</password>
</truststore>
<keystore>
<path>/hivemq-certs/server.jks</path>
<password>s3cr3t</password>
<private-key-password>s3cr3t</private-key-password>
</keystore>
<protocols>
<protocol>TLSv1.3</protocol>
<protocol>TLSv1.2</protocol>
<protocol>TLSv1.1</protocol>
<protocol>TLSv1</protocol>
</protocols>
</tls>
</tls-tcp-listener>
</listeners>
</hivemq>

View File

@@ -0,0 +1,31 @@
# Config file for mosquitto
per_listener_settings true
# Port to use for the default listener.
listener 1883
allow_anonymous true
# Port to use for the default listener with authentication.
listener 1884
password_file /mosquitto/config/mosquitto.passwd
allow_anonymous false
# =================================================================
# Extra listeners
# =================================================================
# TLS listener without client certificate requirement
listener 8883
cafile /mosquitto-certs/ca.crt
certfile /mosquitto-certs/server.crt
keyfile /mosquitto-certs/server.key
require_certificate false
allow_anonymous true
# TLS listener with client certificate requirement
listener 8884
cafile /mosquitto-certs/ca.crt
certfile /mosquitto-certs/server.crt
keyfile /mosquitto-certs/server.key
require_certificate true
allow_anonymous true

View File

@@ -0,0 +1 @@
ci-test-user:$6$QypQBNSQKE5bg6Ec$nzACfxhQ9qiYFByPPM/6GP/9kOWwDzEftN0EJPkS6M0PWqL55jAbBxUO863oWwhJ2q/YaubfLbe3xwwhBuoStQ==

View File

@@ -0,0 +1,11 @@
listeners.tcp.default = 5672
loopback_users.guest = false
mqtt.listeners.tcp.default = 1883
mqtt.listeners.ssl = none
mqtt.allow_anonymous = true
mqtt.default_user = guest
mqtt.default_pass = guest
mqtt.vhost = /
mqtt.exchange = amq.topic
mqtt.subscription_ttl = 1800000

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
allow:
- dependency-type: "development"
schedule:
interval: "daily"
time: "05:00"
timezone: "Europe/Vienna"
labels:
- "composer dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:00"
timezone: "Europe/Vienna"
labels:
- "github actions"

View File

@@ -0,0 +1,25 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- octocat
categories:
- title: Added
labels:
- enhancement
- title: Deprecated
labels:
- deprecated
- title: Removed
labels:
- removed
- title: Fixed
labels:
- bug
- title: Security
labels:
- security
- title: Changed
labels:
- "*"

View File

@@ -0,0 +1,23 @@
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v5
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
with:
comment-summary-in-pr: true
fail-on-scopes: 'runtime, development, unknown'
fail-on-severity: 'low'
license-check: true
vulnerability-check: true

View File

@@ -0,0 +1,140 @@
name: Tests
on:
push:
branches:
- master
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
test-all:
name: Test PHP ${{ matrix.php-version }} using broker [${{ matrix.mqtt-broker }}]
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.1', '8.2', '8.3', '8.4']
mqtt-broker: ['mosquitto-1.6', 'mosquitto-2.0', 'hivemq', 'emqx', 'rabbitmq']
include:
- php-version: '8.4'
mqtt-broker: 'mosquitto-2.0'
run-sonarqube-analysis: true
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: phpunit:9.5.0
coverage: pcov
- name: Setup problem matchers for PHP
run: echo "::add-matcher::${{ runner.tool_cache }}/php.json"
- name: Setup problem matchers for PHPUnit
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install Composer dependencies
run: composer install --prefer-dist
- name: Generate certificates for tests
run: |
sh create-certificates.sh
chmod u+rx,g+rx ${{ github.workspace }}/.ci/tls
chmod a+r ${{ github.workspace }}/.ci/tls/*
- name: Start Mosquitto 1.6 message broker
if: matrix.mqtt-broker == 'mosquitto-1.6'
uses: Namoshek/mosquitto-github-action@v1
with:
version: '1.6'
ports: '1883:1883 1884:1884 8883:8883 8884:8884'
certificates: ${{ github.workspace }}/.ci/tls
config: ${{ github.workspace }}/.ci/mosquitto.conf
password-file: ${{ github.workspace}}/.ci/mosquitto.passwd
- name: Start Mosquitto 2.0 message broker
if: matrix.mqtt-broker == 'mosquitto-2.0'
uses: Namoshek/mosquitto-github-action@v1
with:
version: '2.0'
ports: '1883:1883 1884:1884 8883:8883 8884:8884'
certificates: ${{ github.workspace }}/.ci/tls
config: ${{ github.workspace }}/.ci/mosquitto.conf
password-file: ${{ github.workspace}}/.ci/mosquitto.passwd
- name: Start HiveMQ message broker
if: matrix.mqtt-broker == 'hivemq'
uses: Namoshek/hivemq4-github-action@v1
with:
version: '4.8.5'
ports: '1883:1883 8883:8883 8884:8884'
certificates: ${{ github.workspace }}/.ci/tls
config: ${{ github.workspace }}/.ci/hivemq.xml
- name: Start EMQ X message broker
if: matrix.mqtt-broker == 'emqx'
uses: Namoshek/emqx-github-action@v1.0.2
with:
version: '4.4.3'
ports: '1883:1883'
config: ${{ github.workspace }}/.ci/emqx.conf
- name: Start RabbitMQ message broker
if: matrix.mqtt-broker == 'rabbitmq'
uses: namoshek/rabbitmq-github-action@v1.1.0
with:
version: '3.8.9'
ports: '1883:1883'
config: ${{ github.workspace }}/.ci/rabbitmq.conf
plugins: 'rabbitmq_mqtt'
- name: Wait a bit until MQTT broker has started
run: sleep 45
- name: Run phpunit tests
run: composer test
env:
MQTT_BROKER_HOST: 'localhost'
MQTT_BROKER_PORT: 1883
MQTT_BROKER_PORT_WITH_AUTHENTICATION: ${{ (matrix.mqtt-broker == 'mosquitto-1.6' || matrix.mqtt-broker == 'mosquitto-2.0') && 1884 || 1883 }}
MQTT_BROKER_TLS_PORT: 8883
MQTT_BROKER_TLS_WITH_CLIENT_CERT_PORT: 8884
MQTT_BROKER_USERNAME: ${{ (matrix.mqtt-broker == 'mosquitto-1.6' || matrix.mqtt-broker == 'mosquitto-2.0') && 'ci-test-user' || '' }}
MQTT_BROKER_PASSWORD: ${{ (matrix.mqtt-broker == 'mosquitto-1.6' || matrix.mqtt-broker == 'mosquitto-2.0') && secrets.CI_MOSQUITTO_CI_TEST_USER_PASSWORD || '' }}
SKIP_TLS_TESTS: ${{ matrix.mqtt-broker == 'emqx' || matrix.mqtt-broker == 'rabbitmq' }}
- name: Dump Docker logs on failure
if: failure()
uses: jwalton/gh-docker-logs@v2
- name: Prepare paths for SonarQube analysis
if: matrix.run-sonarqube-analysis
run: |
sed -i "s|$GITHUB_WORKSPACE|/github/workspace|g" phpunit.coverage-clover.xml
sed -i "s|$GITHUB_WORKSPACE|/github/workspace|g" phpunit.report-junit.xml
- name: Run SonarQube analysis
uses: sonarsource/sonarqube-scan-action@v6.0.0
if: matrix.run-sonarqube-analysis
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }}

6
vendor/php-mqtt/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.idea/
.phpunit.result.cache
composer.lock
phpunit.coverage*.xml
phpunit.report*.xml
/vendor/

88
vendor/php-mqtt/client/.phpcs.xml vendored Normal file
View File

@@ -0,0 +1,88 @@
<?xml version="1.0"?>
<ruleset name="php-mqtt Code Style Standard">
<description>php-mqtt Code Style Standard</description>
<rule ref="PSR1"/>
<rule ref="PSR2">
<exclude name="PSR2.Methods.MethodDeclaration.AbstractAfterVisibility"/>
<exclude name="Squiz.ControlStructures.ControlSignature.SpaceAfterCloseParenthesis"/>
</rule>
<rule ref="Generic.Arrays.ArrayIndent">
<exclude name="Generic.Arrays.ArrayIndent.CloseBraceNotNewLine"/>
</rule>
<rule ref="Generic.Classes.DuplicateClassName"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement">
<exclude name="Generic.CodeAnalysis.EmptyStatement.DetectedCatch"/>
</rule>
<rule ref="Generic.CodeAnalysis.ForLoopShouldBeWhileLoop"/>
<rule ref="Generic.CodeAnalysis.ForLoopWithTestFunctionCall"/>
<rule ref="Generic.CodeAnalysis.JumbledIncrementer"/>
<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
<rule ref="Generic.CodeAnalysis.UselessOverridingMethod"/>
<rule ref="Generic.Commenting.Todo">
<exclude-pattern>src/*</exclude-pattern>
</rule>
<rule ref="Generic.ControlStructures.InlineControlStructure"/>
<rule ref="Generic.Files.ByteOrderMark"/>
<rule ref="Generic.Files.LineEndings"/>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="150"/>
<property name="absoluteLineLimit" value="0"/>
</properties>
</rule>
<rule ref="Generic.Formatting.DisallowMultipleStatements"/>
<rule ref="Generic.Formatting.MultipleStatementAlignment"/>
<rule ref="Generic.Formatting.SpaceAfterCast"/>
<rule ref="Generic.Functions.CallTimePassByReference"/>
<rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
<rule ref="Generic.Functions.OpeningFunctionBraceBsdAllman"/>
<rule ref="Generic.Metrics.CyclomaticComplexity">
<properties>
<property name="complexity" value="50"/>
<property name="absoluteComplexity" value="100"/>
</properties>
</rule>
<rule ref="Generic.Metrics.NestingLevel">
<properties>
<property name="nestingLevel" value="10"/>
<property name="absoluteNestingLevel" value="30"/>
</properties>
</rule>
<rule ref="Generic.NamingConventions.ConstructorName"/>
<rule ref="Generic.PHP.LowerCaseConstant"/>
<rule ref="Generic.PHP.DeprecatedFunctions"/>
<rule ref="Generic.PHP.DisallowShortOpenTag"/>
<rule ref="Generic.PHP.ForbiddenFunctions"/>
<rule ref="Generic.WhiteSpace.DisallowTabIndent"/>
<rule ref="Generic.WhiteSpace.ScopeIndent">
<properties>
<property name="indent" value="4"/>
</properties>
</rule>
<rule ref="MySource.PHP.EvalObjectFactory"/>
<rule ref="PEAR.Commenting.ClassComment">
<exclude name="PEAR.Commenting.ClassComment.MissingAuthorTag"/>
<exclude name="PEAR.Commenting.ClassComment.MissingCategoryTag"/>
<exclude name="PEAR.Commenting.ClassComment.MissingLicenseTag"/>
<exclude name="PEAR.Commenting.ClassComment.MissingLinkTag"/>
</rule>
<rule ref="PEAR.Commenting.ClassComment.Missing"/>
<rule ref="PEAR.Commenting.ClassComment.MissingPackageTag"/>
<rule ref="PEAR.Commenting.InlineComment"/>
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
<rule ref="PSR2.Methods.FunctionClosingBrace.SpacingBeforeClose"/>
<rule ref="Squiz.Arrays.ArrayDeclaration.NoCommaAfterLast"/>
<rule ref="Squiz.Functions.MultiLineFunctionDeclaration.NewlineBeforeOpenBrace">
<exclude-pattern>src/*</exclude-pattern>
</rule>
<rule ref="Zend.Files.ClosingTag"/>
<file>src</file>
<arg name="colors"/>
<arg value="sp"/>
<ini name="memory_limit" value="128M"/>
</ruleset>

72
vendor/php-mqtt/client/CHANGELOG.md vendored Normal file
View File

@@ -0,0 +1,72 @@
# Changelog
## Version `v1.0.0`
Significant improvements to the architecture, API and design of the library have been part of `v1.0.0`.
Upgrading should be rather simple for most users though, since the public API has not changed a lot
and only in places which are not used too frequently.
A lot of effort has been put into this summary to document as many changes as possible.
It is impossible to give a guarantee about the completeness of this list though.
You should cover your uses of the library with tests yourself as well.
The following summary compares `v0.3.0` to `v1.0.0`.
### Breaking Changes
- The library does now require PHP 7.4 and supports PHP 8.0. This move was made with the clear intention to drop support for PHP 7.4 at some point.
- The primary interface and class of the library have been renamed to StudlyCaps to follow PSR-2:
- `\PhpMqtt\Client\Contracts\MQTTClient` &rarr; `\PhpMqtt\Client\Contracts\MqttClient`
- `\PhpMqtt\Client\MQTTClient` &rarr; `\PhpMqtt\Client\MqttClient`
- `\PhpMqtt\Client\Exceptions\MQTTClientException` &rarr; `\PhpMqtt\Client\Exceptions\MqttClientException`
- Protocol specific logic has been extracted from the `MQTTClient` class to a new interface `\PhpMqtt\Client\Contracts\MessageProcessor` and the respective implementation for MQTT 3.1, `\PhpMqtt\Client\MessageProcessors\Mqtt31MessageProcessor`.
- The `MessageProcessor` is responsible for parsing and building packages on a byte level.
- Splitting the logic from the main class did not only reduce the overall complexity of the class, it also made testing a lot easier and builds a solid foundation for future development and extension by implementing more protocol versions (like MQTT 5).
- Some `protected` properties have been changed to `private` to ensure they are not manipulated outside the offered scope, which is enforced through getters and setters. This change only affects users which actively inherited their own implementation from the library.
- The QoS 2 message flow is now implemented properly and should just work.
#### Connection Settings
- The `$caFile` parameter of the `MQTTClient` constructor as well as the `$username` and `$password` parameters of the `MQTTClient::connect()` method have been moved to the `ConnectionSettings` class.
- The `ConnectionSettings` use fluent setters for configuration now ([see README](README.md)).
- The `ConnectionSettings`` passed to `MQTTClient::connect()` are now validated and may not contain invalid configuration. In case of invalid configuration, a `\PhpMqtt\Client\Exceptions\ConfigurationInvalidException` is thrown.
- Additional TLS options have been added to the `ConnectionSettings` to support more uses cases with secured connections.
#### Methods
- Most methods can now throw a `\PhpMqtt\Client\Exceptions\RepositoryException` if an interaction with the repository fails. This should happen with the `MemoryRepository` only in exceptional situations, but when implementing persisted repositories, this may happen more frequently and should therefore be considered.
- The `MQTTClient::connect()` method had a parameter called `$sendCleanSessionFlag` while the `MqttClient::connect()` method has the same parameter, but called `$useCleanSession`. The parameters `$username` and `$password` have been removed entirely and are now part of the `ConnectionSettings`.
- The method `MQTTClient::close()` has been renamed to `MqttClient::disconnect()`.
- The parameter `$topic` of `MQTTClient::subscribe()` has been renamed to `$topicFilter` to reflect its meaning (which is a topic, but with wildcards). The `$callback` parameter can be `null` now and has `null` as default.
- The parameter `$topic` of `MQTTClient::unsubscribe()` has been renamed to `$topicFilter` as well.
#### Exceptions
- New exceptions have been introduced and old ones were removed. All exceptions inherit from `\PhpMqtt\Client\Exceptions\MqttClientException` as base. You should ensure your calls to methods of the `MqttClient` handle the exceptions appropriately.
- The exception constants previously defined on the `\PhpMqtt\Client\MQTTClient` class have been moved to the respective exception classes. This change only affects you if you used these constants to render detailed exception information for your users.
#### Repositories
- The `\PhpMqtt\Client\Contracts\Repository` interface has been changed significantly and summarizing all changes would be quite hard anyway. We therefore encourage you to have a look at the interface again and update your own implementation(s) of it, if you have any.
#### Logger
- The `\PhpMqtt\Client\Logger` implementation of `Psr\Log\LoggerInterface` does now decorate the log output with details about the MQTT client (format: `MQTT [{host}:{port}] [{clientId}] {message}`).
### Additions
- It is now possible to register event handlers for received messages. In combination with subscriptions without a callback, this allows to use centralized logic for multiple subscriptions. It also can be used for centralized logging, for example.
- A lot of unit and integration tests have been added which cover most parts of the library, especially the non-exception paths.
- All unit tests, integration tests, and the code style are enforced using a GitHub Actions workflow which runs under Ubuntu against multiple MQTT brokers (currently Mosquitto, HiveMQ and EMQ X). Contributing became easier therefore, but we expect that tests are added for changes and additions.
- To run the tests locally, an MQTT broker without authorization needs to run at `localhost:1883` (or the configuration in `phpunit.xml` is changed instead).
- The project is now analyzed using [sonarcloud.io](https://sonarcloud.io/dashboard?id=php-mqtt_client) which helps us keep up the high standards of the library.
#### Methods
- `MqttClient::isConnected()`: returns `true` if a connection is established (socket opened), and `false` otherwise.
- `MqttClient::getReceivedBytes()`: returns the number of raw bytes received from the broker (this includes meta information and not only message contents).
- `MqttClient::getSentBytes()`: returns the number of raw bytes sent to the broker (this includes meta information and not only message contents).
### Removals
_No functionality has been removed in this version._

21
vendor/php-mqtt/client/LICENSE.md vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Marvin Mall <marvin-mall@msn.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

424
vendor/php-mqtt/client/README.md vendored Normal file
View File

@@ -0,0 +1,424 @@
# php-mqtt/client
[![Latest Stable Version](https://poser.pugx.org/php-mqtt/client/v)](https://packagist.org/packages/php-mqtt/client)
[![Total Downloads](https://poser.pugx.org/php-mqtt/client/downloads)](https://packagist.org/packages/php-mqtt/client)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=php-mqtt_client&metric=coverage)](https://sonarcloud.io/dashboard?id=php-mqtt_client)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=php-mqtt_client&metric=alert_status)](https://sonarcloud.io/dashboard?id=php-mqtt_client)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=php-mqtt_client&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=php-mqtt_client)
[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=php-mqtt_client&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=php-mqtt_client)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=php-mqtt_client&metric=security_rating)](https://sonarcloud.io/dashboard?id=php-mqtt_client)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=php-mqtt_client&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=php-mqtt_client)
[![License](https://poser.pugx.org/php-mqtt/client/license)](https://packagist.org/packages/php-mqtt/client)
[`php-mqtt/client`](https://packagist.org/packages/php-mqtt/client) was created by, and is maintained
by [Marvin Mall](https://github.com/namoshek).
It allows you to connect to an MQTT broker where you can publish messages and subscribe to topics.
The current implementation supports all QoS levels ([with limitations](#limitations)).
## Installation
The package is available on [packagist.org](https://packagist.org/packages/php-mqtt/client) and can be installed using `composer`:
```bash
composer require php-mqtt/client
```
The package requires PHP version 8.0 or higher.
## Usage
In the following, only a few very basic examples are given. For more elaborate examples, have a look at the
[`php-mqtt/client-examples` repository](https://github.com/php-mqtt/client-examples).
### Publish
A very basic publish example using QoS 0 requires only three steps: connect, publish and disconnect
```php
$server = 'some-broker.example.com';
$port = 1883;
$clientId = 'test-publisher';
$mqtt = new \PhpMqtt\Client\MqttClient($server, $port, $clientId);
$mqtt->connect();
$mqtt->publish('php-mqtt/client/test', 'Hello World!', 0);
$mqtt->disconnect();
```
If you do not want to pass a `$clientId`, a random one will be generated for you. This will basically force a clean session implicitly.
Be also aware that most of the methods can throw exceptions. The above example does not add any exception handling for brevity.
### Subscribe
Subscribing is a little more complex than publishing as it requires to run an event loop which reads, parses and handles messages from the broker:
```php
$server = 'some-broker.example.com';
$port = 1883;
$clientId = 'test-subscriber';
$mqtt = new \PhpMqtt\Client\MqttClient($server, $port, $clientId);
$mqtt->connect();
$mqtt->subscribe('php-mqtt/client/test', function ($topic, $message, $retained, $matchedWildcards) {
echo sprintf("Received message on topic [%s]: %s\n", $topic, $message);
}, 0);
$mqtt->loop(true);
$mqtt->disconnect();
```
While the loop is active, you can use `$mqtt->interrupt()` to send an interrupt signal to the loop.
This will terminate the loop before it starts its next iteration. You can call this method using `pcntl_signal(SIGINT, $handler)` for example:
```php
pcntl_async_signals(true);
$clientId = 'test-subscriber';
$mqtt = new \PhpMqtt\Client\MqttClient($server, $port, $clientId);
pcntl_signal(SIGINT, function (int $signal, $info) use ($mqtt) {
$mqtt->interrupt();
});
$mqtt->connect();
$mqtt->subscribe('php-mqtt/client/test', function ($topic, $message, $retained, $matchedWildcards) {
echo sprintf("Received message on topic [%s]: %s\n", $topic, $message);
}, 0);
$mqtt->loop(true);
$mqtt->disconnect();
```
### Client Settings
As shown in the examples above, the `MqttClient` takes the server, port and client id as first, second and third parameter.
As fourth parameter, the protocol level can be passed. Currently supported is MQTT v3.1,
available as constant `MqttClient::MQTT_3_1`.
A fifth parameter allows passing a repository (currently, only a `MemoryRepository` is available by default).
Lastly, a logger can be passed as sixth parameter. If none is given, a null logger is used instead.
Example:
```php
$mqtt = new \PhpMqtt\Client\MqttClient(
$server,
$port,
$clientId,
\PhpMqtt\Client\MqttClient::MQTT_3_1,
new \PhpMqtt\Client\Repositories\MemoryRepository(),
new Logger()
);
```
The `Logger` must implement the `Psr\Log\LoggerInterface`.
### Connection Settings
The `connect()` method of the `MqttClient` takes two optional parameters:
1. A `ConnectionSettings` instance
2. A `boolean` flag indicating whether a clean session should be requested (a random client id does this implicitly)
Example:
```php
$mqtt = new \PhpMqtt\Client\MqttClient($server, $port, $clientId);
$connectionSettings = (new \PhpMqtt\Client\ConnectionSettings)
->setConnectTimeout(3)
->setUseTls(true)
->setTlsSelfSignedAllowed(true);
$mqtt->connect($connectionSettings, true);
```
The `ConnectionSettings` class provides a few settings through a fluent interface. The type itself is immutable,
and a new `ConnectionSettings` instance will be created for each added option.
This also prevents changes to the connection settings after a connection has been established.
The following is a complete list of options with their respective default:
```php
$connectionSettings = (new \PhpMqtt\Client\ConnectionSettings)
// The username used for authentication when connecting to the broker.
->setUsername(null)
// The password used for authentication when connecting to the broker.
->setPassword(null)
// Whether to use a blocking socket when publishing messages or not.
// Normally, this setting can be ignored. When publishing large messages with multiple kilobytes in size,
// a blocking socket may be required if the receipt buffer of the broker is not large enough.
//
// Note: This setting has no effect on subscriptions, only on the publishing of messages.
->useBlockingSocket(false)
// The connect timeout defines the maximum amount of seconds the client will try to establish
// a socket connection with the broker. The value cannot be less than 1 second.
->setConnectTimeout(60)
// The socket timeout is the maximum amount of idle time in seconds for the socket connection.
// If no data is read or sent for the given amount of seconds, the socket will be closed.
// The value cannot be less than 1 second.
->setSocketTimeout(5)
// The resend timeout is the number of seconds the client will wait before sending a duplicate
// of pending messages without acknowledgement. The value cannot be less than 1 second.
->setResendTimeout(10)
// This flag determines whether the client will try to reconnect automatically
// if it notices a disconnect while sending data.
// The setting cannot be used together with the clean session flag.
->setReconnectAutomatically(false)
// Defines the maximum number of reconnect attempts until the client gives up.
// This setting is only relevant if setReconnectAutomatically() is set to true.
->setMaxReconnectAttempts(3)
// Defines the delay between reconnect attempts in milliseconds.
// This setting is only relevant if setReconnectAutomatically() is set to true.
->setDelayBetweenReconnectAttempts(0)
// The keep alive interval is the number of seconds the client will wait without sending a message
// until it sends a keep alive signal (ping) to the broker. The value cannot be less than 1 second
// and may not be higher than 65535 seconds. A reasonable value is 10 seconds (the default).
->setKeepAliveInterval(10)
// If the broker should publish a last will message in the name of the client when the client
// disconnects abruptly, this setting defines the topic on which the message will be published.
//
// A last will message will only be published if both this setting as well as the last will
// message are configured.
->setLastWillTopic(null)
// If the broker should publish a last will message in the name of the client when the client
// disconnects abruptly, this setting defines the message which will be published.
//
// A last will message will only be published if both this setting as well as the last will
// topic are configured.
->setLastWillMessage(null)
// The quality of service level the last will message of the client will be published with,
// if it gets triggered.
->setLastWillQualityOfService(0)
// This flag determines if the last will message of the client will be retained, if it gets
// triggered. Using this setting can be handy to signal that a client is offline by publishing
// a retained offline state in the last will and an online state as first message on connect.
->setRetainLastWill(false)
// This flag determines if TLS should be used for the connection. The port which is used to
// connect to the broker must support TLS connections.
->setUseTls(false)
// This flag determines if the peer certificate is verified, if TLS is used.
->setTlsVerifyPeer(true)
// This flag determines if the peer name is verified, if TLS is used.
->setTlsVerifyPeerName(true)
// This flag determines if self signed certificates of the peer should be accepted.
// Setting this to TRUE implies a security risk and should be avoided for production
// scenarios and public services.
->setTlsSelfSignedAllowed(false)
// The path to a Certificate Authority certificate which is used to verify the peer
// certificate, if TLS is used.
->setTlsCertificateAuthorityFile(null)
// The path to a directory containing Certificate Authority certificates which are
// used to verify the peer certificate, if TLS is used.
->setTlsCertificateAuthorityPath(null)
// The path to a client certificate file used for authentication, if TLS is used.
//
// The client certificate must be PEM encoded. It may optionally contain the
// certificate chain of issuers.
->setTlsClientCertificateFile(null)
// The path to a client certificate key file used for authentication, if TLS is used.
//
// This option requires ConnectionSettings::setTlsClientCertificateFile() to be used as well.
->setTlsClientCertificateKeyFile(null)
// The passphrase used to decrypt the private key of the client certificate,
// which in return is used for authentication, if TLS is used.
//
// This option requires ConnectionSettings::setTlsClientCertificateFile() and
// ConnectionSettings::setTlsClientCertificateKeyFile() to be used as well.
->setTlsClientCertificateKeyPassphrase(null);
// The TLS ALPN is used to establish a TLS encrypted mqtt connection on port 443,
// which usually is reserved for TLS encrypted HTTP traffic.
->setTlsAlpn(null);
```
### Hooks
The client includes a flexible and powerful hook system to allow custom behaviors during different stages of the MQTT lifecycle. Hooks are registered using closures and can be added or removed dynamically at runtime.
> 💡 All hooks receive the MQTT client instance (`MqttClient`) as their first argument, allowing full access to the client's capabilities from within the hook.
> 💡 Each hook is executed in a `try-catch` block to ensure no individual exception can crash the loop or hook processing.
#### Loop Event Hooks
Called on each iteration of the MQTT client's loop. This hook is especially useful to implement timeouts or other deadlock-prevention logic.
##### Register
```php
$callback = function (MqttClient $mqtt, float $elapsedTime) {
echo "Running for {$elapsedTime} seconds already.";
};
$mqtt->registerLoopEventHandler($callback);
```
##### Unregister
```php
$mqtt->unregisterLoopEventHandler($callback); // Unregister specific event handler
$mqtt->unregisterLoopEventHandler(); // Unregister all event handlers
```
#### Publish Event Hooks
Triggered every time a message is published to the broker. This hook is useful to implement centralized logging or metrics.
##### Register
```php
$callback = function (
MqttClient $mqtt,
string $topic,
string $message,
?int $messageId,
int $qualityOfService,
bool $retain
) {
echo "Published to [{$topic}]: {$message}";
};
$mqtt->registerPublishEventHandler($callback);
```
##### Unregister
```php
$mqtt->unregisterPublishEventHandler($callback); // Unregister specific event handler
$mqtt->unregisterPublishEventHandler(); // Unregister all event handlers
```
#### Message Received Hooks
Executed when a message is received from the broker as part of a subscription. This hook is useful to implement centralized logging or metrics.
##### Register
```php
$callback = function (
MqttClient $mqtt,
string $topic,
string $message,
int $qualityOfService,
bool $retained
) {
echo "Message on [{$topic}]: {$message}";
};
$mqtt->registerMessageReceivedEventHandler($callback);
```
##### Unregister
```php
$mqtt->unregisterMessageReceivedEventHandler($callback); // Unregister specific event handler
$mqtt->unregisterMessageReceivedEventHandler(); // Unregister all event handlers
```
#### Connected Hooks
Invoked when the client connects to the broker (initial or auto-reconnect).
##### Register
```php
$callback = function (MqttClient $mqtt, bool $isAutoReconnect) {
echo $isAutoReconnect ? "Auto-reconnected!" : "Connected!";
};
$mqtt->registerConnectedEventHandler($callback);
```
##### Unregister
```php
$mqtt->unregisterConnectedEventHandler($callback); // Unregister specific event handler
$mqtt->unregisterConnectedEventHandler(); // Unregister all event handlers
```
## Features
- Supported MQTT Versions
- [x] v3 (just don't use v3.1 features like username & password)
- [x] v3.1
- [x] v3.1.1
- [ ] v5.0
- Transport
- [x] TCP (unsecured)
- [x] TLS (secured, verifies the peer using a certificate authority file)
- Connect
- [x] Last Will
- [x] Message Retention
- [x] Authentication (username & password)
- [x] TLS encrypted connections
- [ ] Clean Session (can be set and sent, but the client has no persistence for QoS 2 messages)
- Publish
- [x] QoS Level 0
- [x] QoS Level 1 (limitation: no persisted state across sessions)
- [x] QoS Level 2 (limitation: no persisted state across sessions)
- Subscribe
- [x] QoS Level 0
- [x] QoS Level 1
- [x] QoS Level 2 (limitation: no persisted state across sessions)
- Supported Message Length: unlimited _(no limits enforced, although the MQTT protocol supports only up to 256MB which one shouldn't use even remotely anyway)_
- Logging possible (`Psr\Log\LoggerInterface` can be passed to the client)
- Persistence Drivers
- [x] In-Memory Driver
- [ ] Redis Driver
## Limitations
- Message flows with a QoS level higher than 0 are not persisted as the default implementation uses an in-memory repository for data.
To avoid issues with broken message flows, use the clean session flag to indicate that you don't care about old data.
It will not only instruct the broker to consider the connection new (without previous state), but will also reset the registered repository.
## Developing & Testing
### Certificates (TLS)
To run the tests (especially the TLS tests), you will need to create certificates. A command has been provided for this:
```sh
sh create-certificates.sh
```
This will create all required certificates in the `.ci/tls/` directory. The same script is used for continuous integration as well.
### MQTT Broker for Testing
Running the tests expects an MQTT broker to be running. The easiest way to run an MQTT broker is through Docker:
```sh
docker run --rm -it \
-p 1883:1883 \
-p 1884:1884 \
-p 8883:8883 \
-p 8884:8884 \
-v $(pwd)/.ci/tls:/mosquitto-certs \
-v $(pwd)/.ci/mosquitto.conf:/mosquitto/config/mosquitto.conf \
-v $(pwd)/.ci/mosquitto.passwd:/mosquitto/config/mosquitto.passwd \
eclipse-mosquitto:1.6
```
When run from the project directory, this will spawn a Mosquitto MQTT broker configured with the generated TLS certificates and a custom configuration.
In case you intend to run a different broker or using a different method, or use a public broker instead,
you will need to adjust the environment variables defined in `phpunit.xml` accordingly.
## License
`php-mqtt/client` is open-sourced software licensed under the [MIT license](LICENSE.md).

53
vendor/php-mqtt/client/composer.json vendored Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "php-mqtt/client",
"description": "An MQTT client written in and for PHP.",
"type": "library",
"keywords": [
"mqtt",
"client",
"publish",
"subscribe"
],
"license": "MIT",
"authors": [
{
"name": "Marvin Mall",
"email": "marvin-mall@msn.com",
"role": "developer"
}
],
"autoload": {
"psr-4": {
"PhpMqtt\\Client\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"require": {
"php": "^8.0",
"psr/log": "^1.1|^2.0|^3.0",
"myclabs/php-enum": "^1.7"
},
"require-dev": {
"phpunit/php-invoker": "^3.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "^3.5"
},
"suggest": {
"ext-redis": "Required for the RedisRepository"
},
"scripts": {
"fix:cs": "vendor/bin/phpcbf",
"test": [
"@test:cs",
"@test:all"
],
"test:all": "vendor/bin/phpunit --testdox --log-junit=phpunit.report-junit.xml --coverage-clover=phpunit.coverage-clover.xml --coverage-text",
"test:cs": "vendor/bin/phpcs",
"test:feature": "vendor/bin/phpunit --testsuite=Feature --testdox --log-junit=phpunit.report-junit.xml --coverage-clover=phpunit.coverage-clover.xml --coverage-text",
"test:unit": "vendor/bin/phpunit --testsuite=Unit --testdox --log-junit=phpunit.report-junit.xml --coverage-clover=phpunit.coverage-clover.xml --coverage-text"
}
}

View File

@@ -0,0 +1,30 @@
#!/bin/sh
# Generate a new CA certificate and key.
openssl genrsa -out .ci/tls/ca.key 2048
openssl req -x509 -new -nodes -key .ci/tls/ca.key -days 1 -out .ci/tls/ca.crt -subj "/C=AT/ST=Vorarlberg/CN=php-mqtt Test CA"
# Copy ca.crt to a file named by the hashed subject of the certificate. This is required for PHP's capath option to find the certificate.
cp .ci/tls/ca.crt .ci/tls/$(openssl x509 -hash -noout -in .ci/tls/ca.crt).0
# Create a Java Trust Store from the CA certificate. This is used by HiveMQ.
keytool -import -file .ci/tls/ca.crt -alias ca -keystore .ci/tls/ca.jks -storepass s3cr3t -trustcacerts -noprompt
# Generate a new server certificate and key, signed by the created CA.
openssl genrsa -out .ci/tls/server.key 2048
openssl req -new -key .ci/tls/server.key -out .ci/tls/server.csr -sha512 -subj "/C=AT/ST=Vorarlberg/CN=localhost"
openssl x509 -req -in .ci/tls/server.csr -CA .ci/tls/ca.crt -CAkey .ci/tls/ca.key -CAcreateserial -out .ci/tls/server.crt -days 1 -sha512
# Generate a Java Key Store from the server certificate. This is used by HiveMQ.
openssl pkcs12 -export -in .ci/tls/server.crt -inkey .ci/tls/server.key -out .ci/tls/server.p12 -passout pass:s3cr3t
keytool -importkeystore -srckeystore .ci/tls/server.p12 -srcstoretype PKCS12 -destkeystore .ci/tls/server.jks -deststoretype JKS -srcstorepass s3cr3t -deststorepass s3cr3t -noprompt
# Generate a client certificate without passphrase, signed by the created CA.
openssl genrsa -out .ci/tls/client.key 2048
openssl req -new -key .ci/tls/client.key -out .ci/tls/client.csr -sha512 -subj "/C=AT/ST=Vorarlberg/CN=localhost"
openssl x509 -req -in .ci/tls/client.csr -CA .ci/tls/ca.crt -CAkey .ci/tls/ca.key -CAcreateserial -out .ci/tls/client.crt -days 1 -sha256
# Generate a client certificate with passphrase, signed by the created CA.
openssl genrsa -aes128 -passout pass:s3cr3t -out .ci/tls/client2.key 2048
openssl req -new -key .ci/tls/client2.key -passin pass:s3cr3t -out .ci/tls/client2.csr -sha512 -subj "/C=AT/ST=Vorarlberg/CN=localhost"
openssl x509 -req -in .ci/tls/client2.csr -CA .ci/tls/ca.crt -CAkey .ci/tls/ca.key -CAcreateserial -out .ci/tls/client2.crt -days 1 -sha256

34
vendor/php-mqtt/client/phpunit.xml vendored Normal file
View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
enforceTimeLimit="true"
defaultTimeLimit="3"
timeoutForSmallTests="2"
timeoutForMediumTests="5"
timeoutForLargeTests="10"
>
<php>
<env name="MQTT_BROKER_HOST" value="localhost"/>
<env name="MQTT_BROKER_PORT" value="1883"/>
<env name="MQTT_BROKER_PORT_WITH_AUTHENTICATION" value="1884"/>
<env name="MQTT_BROKER_TLS_PORT" value="8883"/>
<env name="MQTT_BROKER_TLS_WITH_CLIENT_CERT_PORT" value="8884"/>
<env name="TLS_CERT_DIR" value=".ci/tls"/>
<env name="SKIP_TLS_TESTS" value="false"/>
</php>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>

View File

@@ -0,0 +1,18 @@
sonar.organization=php-mqtt
sonar.projectKey=php-mqtt_client
# Paths are relative to the sonar-project.properties file.
sonar.sources=src
sonar.tests=tests
# Test report and code coverage related settings.
sonar.php.tests.reportPath=phpunit.report-junit.xml
sonar.php.coverage.reportPaths=phpunit.coverage-clover.xml
# Encoding of the source code. Default is default system encoding.
sonar.sourceEncoding=UTF-8
# Links for sonarcloud.io page.
sonar.links.ci=https://github.com/php-mqtt/client/actions
sonar.links.scm=https://github.com/php-mqtt/client
sonar.links.issue=https://github.com/php-mqtt/client/issues

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Concerns;
/**
* Provides common methods used to generate random client ids.
*
* @package PhpMqtt\Client\Concerns
*/
trait GeneratesRandomClientIds
{
/**
* Generates a random client id in the form of an md5 hash.
*/
protected function generateRandomClientId(): string
{
return substr(md5(uniqid((string) random_int(0, PHP_INT_MAX), true)), 0, 20);
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Concerns;
use PhpMqtt\Client\Contracts\MqttClient;
/**
* Contains common methods and properties necessary to offer hooks.
*
* @mixin MqttClient
* @package PhpMqtt\Client\Concerns
*/
trait OffersHooks
{
/** @var \SplObjectStorage|array<\Closure> */
private $loopEventHandlers;
/** @var \SplObjectStorage|array<\Closure> */
private $publishEventHandlers;
/** @var \SplObjectStorage|array<\Closure> */
private $messageReceivedEventHandlers;
/** @var \SplObjectStorage|array<\Closure> */
private $connectedEventHandlers;
/**
* Needs to be called in order to initialize the trait.
*/
protected function initializeEventHandlers(): void
{
$this->loopEventHandlers = new \SplObjectStorage();
$this->publishEventHandlers = new \SplObjectStorage();
$this->messageReceivedEventHandlers = new \SplObjectStorage();
$this->connectedEventHandlers = new \SplObjectStorage();
}
/**
* Registers a loop event handler which is called each iteration of the loop.
* This event handler can be used for example to interrupt the loop under
* certain conditions.
*
* The loop event handler is passed the MQTT client instance as first and
* the elapsed time which the loop is already running for as second
* parameter. The elapsed time is a float containing seconds.
*
* Example:
* ```php
* $mqtt->registerLoopEventHandler(function (
* MqttClient $mqtt,
* float $elapsedTime
* ) use ($logger) {
* $logger->info("Running for [{$elapsedTime}] seconds already.");
* });
* ```
*
* Multiple event handlers can be registered at the same time.
*/
public function registerLoopEventHandler(\Closure $callback): MqttClient
{
$this->loopEventHandlers->attach($callback);
/** @var MqttClient $this */
return $this;
}
/**
* Unregisters a loop event handler which prevents it from being called
* in the future.
*
* This does not affect other registered event handlers. It is possible
* to unregister all registered event handlers by passing null as callback.
*/
public function unregisterLoopEventHandler(?\Closure $callback = null): MqttClient
{
if ($callback === null) {
$this->loopEventHandlers->removeAll($this->loopEventHandlers);
} else {
$this->loopEventHandlers->detach($callback);
}
/** @var MqttClient $this */
return $this;
}
/**
* Runs all registered loop event handlers with the given parameters.
* Each event handler is executed in a try-catch block to avoid spilling exceptions.
*/
private function runLoopEventHandlers(float $elapsedTime): void
{
foreach ($this->loopEventHandlers as $handler) {
try {
call_user_func($handler, $this, $elapsedTime);
} catch (\Throwable $e) {
$this->logger->error('Loop hook callback threw exception.', ['exception' => $e]);
}
}
}
/**
* Registers a loop event handler which is called when a message is published.
*
* The loop event handler is passed the MQTT client as first, the topic as
* second and the message as third parameter. As fourth parameter, the message identifier
* will be passed, which can be null in case of QoS 0. The QoS level as well as the retained
* flag will also be passed as fifth and sixth parameters.
*
* Example:
* ```php
* $mqtt->registerPublishEventHandler(function (
* MqttClient $mqtt,
* string $topic,
* string $message,
* ?int $messageId,
* int $qualityOfService,
* bool $retain
* ) use ($logger) {
* $logger->info("Sending message on topic [{$topic}]: {$message}");
* });
* ```
*
* Multiple event handlers can be registered at the same time.
*/
public function registerPublishEventHandler(\Closure $callback): MqttClient
{
$this->publishEventHandlers->attach($callback);
/** @var MqttClient $this */
return $this;
}
/**
* Unregisters a publish event handler which prevents it from being called
* in the future.
*
* This does not affect other registered event handlers. It is possible
* to unregister all registered event handlers by passing null as callback.
*/
public function unregisterPublishEventHandler(?\Closure $callback = null): MqttClient
{
if ($callback === null) {
$this->publishEventHandlers->removeAll($this->publishEventHandlers);
} else {
$this->publishEventHandlers->detach($callback);
}
/** @var MqttClient $this */
return $this;
}
/**
* Runs all the registered publish event handlers with the given parameters.
* Each event handler is executed in a try-catch block to avoid spilling exceptions.
*/
private function runPublishEventHandlers(string $topic, string $message, ?int $messageId, int $qualityOfService, bool $retain): void
{
foreach ($this->publishEventHandlers as $handler) {
try {
call_user_func($handler, $this, $topic, $message, $messageId, $qualityOfService, $retain);
} catch (\Throwable $e) {
$this->logger->error('Publish hook callback threw exception for published message on topic [{topic}].', [
'topic' => $topic,
'exception' => $e,
]);
}
}
}
/**
* Registers an event handler which is called when a message is received from the broker.
*
* The message received event handler is passed the MQTT client as first, the topic as
* second and the message as third parameter. As fourth parameter, the QoS level will be
* passed and the retained flag as fifth.
*
* Example:
* ```php
* $mqtt->registerReceivedMessageEventHandler(function (
* MqttClient $mqtt,
* string $topic,
* string $message,
* int $qualityOfService,
* bool $retained
* ) use ($logger) {
* $logger->info("Received message on topic [{$topic}]: {$message}");
* });
* ```
*
* Multiple event handlers can be registered at the same time.
*/
public function registerMessageReceivedEventHandler(\Closure $callback): MqttClient
{
$this->messageReceivedEventHandlers->attach($callback);
/** @var MqttClient $this */
return $this;
}
/**
* Unregisters a message received event handler which prevents it from being called in the future.
*
* This does not affect other registered event handlers. It is possible
* to unregister all registered event handlers by passing null as callback.
*/
public function unregisterMessageReceivedEventHandler(?\Closure $callback = null): MqttClient
{
if ($callback === null) {
$this->messageReceivedEventHandlers->removeAll($this->messageReceivedEventHandlers);
} else {
$this->messageReceivedEventHandlers->detach($callback);
}
/** @var MqttClient $this */
return $this;
}
/**
* Runs all the registered message received event handlers with the given parameters.
* Each event handler is executed in a try-catch block to avoid spilling exceptions.
*/
private function runMessageReceivedEventHandlers(string $topic, string $message, int $qualityOfService, bool $retained): void
{
foreach ($this->messageReceivedEventHandlers as $handler) {
try {
call_user_func($handler, $this, $topic, $message, $qualityOfService, $retained);
} catch (\Throwable $e) {
$this->logger->error('Received message hook callback threw exception for received message on topic [{topic}].', [
'topic' => $topic,
'exception' => $e,
]);
}
}
}
/**
* Registers an event handler which is called when the client established a connection to the broker.
* This also includes manual reconnects as well as auto-reconnects by the client itself.
*
* The event handler is passed the MQTT client as first argument,
* followed by a flag which indicates whether an auto-reconnect occurred as second argument.
*
* Example:
* ```php
* $mqtt->registerConnectedEventHandler(function (
* MqttClient $mqtt,
* bool $isAutoReconnect
* ) use ($logger) {
* if ($isAutoReconnect) {
* $logger->info("Client successfully auto-reconnected to the broker.);
* } else {
* $logger->info("Client successfully connected to the broker.");
* }
* });
* ```
*
* Multiple event handlers can be registered at the same time.
*/
public function registerConnectedEventHandler(\Closure $callback): MqttClient
{
$this->connectedEventHandlers->attach($callback);
/** @var MqttClient $this */
return $this;
}
/**
* Unregisters a connected event handler which prevents it from being called in the future.
*
* This does not affect other registered event handlers. It is possible
* to unregister all registered event handlers by passing null as callback.
*/
public function unregisterConnectedEventHandler(?\Closure $callback = null): MqttClient
{
if ($callback === null) {
$this->connectedEventHandlers->removeAll($this->connectedEventHandlers);
} else {
$this->connectedEventHandlers->detach($callback);
}
/** @var MqttClient $this */
return $this;
}
/**
* Runs all the registered connected event handlers.
* Each event handler is executed in a try-catch block to avoid spilling exceptions.
*/
private function runConnectedEventHandlers(bool $isAutoReconnect): void
{
foreach ($this->connectedEventHandlers as $handler) {
try {
call_user_func($handler, $this, $isAutoReconnect);
} catch (\Throwable $e) {
$this->logger->error('Connected hook callback threw exception.', ['exception' => $e]);
}
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Concerns;
/**
* Provides common methods to encode data before sending it to a broker
* and to decode data received from a broker.
*
* @package PhpMqtt\Client\Concerns
*/
trait TranscodesData
{
/**
* Creates a string which is prefixed with its own length as bytes.
* This means a string like 'hello world' will become
*
* \x00\x0bhello world
*
* where \x00\0x0b is the hex representation of 00000000 00001011 = 11
*/
protected function buildLengthPrefixedString(string $data): string
{
$length = strlen($data);
$msb = $length >> 8;
$lsb = $length % 256;
return chr($msb) . chr($lsb) . $data;
}
/**
* Converts the given string to a number, assuming it is an MSB encoded message id.
* MSB means preceding characters have higher value.
*/
protected function decodeMessageId(string $encodedMessageId): int
{
$length = strlen($encodedMessageId);
$result = 0;
foreach (str_split($encodedMessageId) as $index => $char) {
$result += ord($char) << (($length - 1) * 8 - ($index * 8));
}
return $result;
}
/**
* Encodes the given message identifier as string.
*/
protected function encodeMessageId(int $messageId): string
{
return chr($messageId >> 8) . chr($messageId % 256);
}
/**
* Encodes the length of a message as string, so it can be transmitted
* over the wire.
*/
protected function encodeMessageLength(int $length): string
{
$result = '';
do {
$digit = $length % 128;
$length = $length >> 7;
// if there are more digits to encode, set the top bit of this digit
if ($length > 0) {
$digit = ($digit | 0x80);
}
$result .= chr($digit);
} while ($length > 0);
return $result;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Concerns;
use PhpMqtt\Client\ConnectionSettings;
use PhpMqtt\Client\Exceptions\ConfigurationInvalidException;
use PhpMqtt\Client\MqttClient;
/**
* Provides methods to validate the configuration of an {@see MqttClient} and
* the {@see ConnectionSettings} being used to connect to a broker.
*
* @package PhpMqtt\Client\Concerns
*/
trait ValidatesConfiguration
{
/**
* Ensures the given connection settings are valid. If they are not valid,
* which means they are misconfigured, an exception containing information about
* the configuration error is thrown.
*
* @throws ConfigurationInvalidException
*/
protected function ensureConnectionSettingsAreValid(ConnectionSettings $settings): void
{
if ($settings->getConnectTimeout() < 1) {
throw new ConfigurationInvalidException('The connect timeout cannot be less than 1 second.');
}
if ($settings->getSocketTimeout() < 1) {
throw new ConfigurationInvalidException('The socket timeout cannot be less than 1 second.');
}
if ($settings->getResendTimeout() < 1) {
throw new ConfigurationInvalidException('The resend timeout cannot be less than 1 second.');
}
if ($settings->getKeepAliveInterval() < 1 || $settings->getKeepAliveInterval() > 65535) {
throw new ConfigurationInvalidException('The keep alive interval must be a value in the range of 1 to 65535 seconds.');
}
if ($settings->getMaxReconnectAttempts() < 1) {
throw new ConfigurationInvalidException('The maximum reconnect attempts cannot be fewer than 1.');
}
if ($settings->getDelayBetweenReconnectAttempts() < 0) {
throw new ConfigurationInvalidException('The delay between reconnect attempts cannot be lower than 0.');
}
if ($settings->getUsername() !== null && trim($settings->getUsername()) === '') {
throw new ConfigurationInvalidException('The username may not consist of white space only.');
}
if ($settings->getLastWillTopic() !== null && trim($settings->getLastWillTopic()) === '') {
throw new ConfigurationInvalidException('The last will topic may not consist of white space only.');
}
if ($settings->getLastWillQualityOfService() < MqttClient::QOS_AT_MOST_ONCE
|| $settings->getLastWillQualityOfService() > MqttClient::QOS_EXACTLY_ONCE) {
throw new ConfigurationInvalidException('The QoS for the last will must be a value in the range of 0 to 2.');
}
if ($settings->getTlsCertificateAuthorityFile() !== null && !is_file($settings->getTlsCertificateAuthorityFile())) {
throw new ConfigurationInvalidException('The Certificate Authority file setting must contain the path to a regular file.');
}
if ($settings->getTlsCertificateAuthorityPath() !== null && !is_dir($settings->getTlsCertificateAuthorityPath())) {
throw new ConfigurationInvalidException('The Certificate Authority path setting must contain the path to a directory.');
}
if ($settings->getTlsClientCertificateFile() !== null && !is_file($settings->getTlsClientCertificateFile())) {
throw new ConfigurationInvalidException('The client certificate file setting must contain the path to a regular file.');
}
if ($settings->getTlsClientCertificateKeyFile() !== null && !is_file($settings->getTlsClientCertificateKeyFile())) {
throw new ConfigurationInvalidException('The client certificate key file setting must contain the path to a regular file.');
}
if ($settings->getTlsClientCertificateKeyFile() !== null && $settings->getTlsClientCertificateFile() === null) {
throw new ConfigurationInvalidException('Using a client certificate key file without certificate does not work.');
}
if ($settings->getTlsClientCertificateKeyPassphrase() !== null && $settings->getTlsClientCertificateKeyFile() === null) {
throw new ConfigurationInvalidException('Using a client certificate key passphrase without key file does not work.');
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Concerns;
/**
* Provides common methods to work with buffers.
*
* @package PhpMqtt\Client\Concerns
*/
trait WorksWithBuffers
{
/**
* Pops the first $limit bytes from the given buffer and returns them.
*/
protected function pop(string &$buffer, int $limit): string
{
$limit = min(strlen($buffer), $limit);
$result = substr($buffer, 0, $limit);
$buffer = substr($buffer, $limit);
return $result;
}
}

View File

@@ -0,0 +1,555 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client;
/**
* The settings used during connection to a broker.
*
* This class is immutable and all setters return a clone of the original class because
* connection settings must not change once passed to MqttClient.
*
* @package PhpMqtt\Client
*/
class ConnectionSettings
{
private ?string $username = null;
private ?string $password = null;
private bool $useBlockingSocket = false;
private int $connectTimeout = 60;
private int $socketTimeout = 5;
private int $resendTimeout = 10;
private int $keepAliveInterval = 10;
private bool $reconnectAutomatically = false;
private int $maxReconnectAttempts = 3;
private int $delayBetweenReconnectAttempts = 0;
private ?string $lastWillTopic = null;
private ?string $lastWillMessage = null;
private int $lastWillQualityOfService = 0;
private bool $lastWillRetain = false;
private bool $useTls = false;
private bool $tlsVerifyPeer = true;
private bool $tlsVerifyPeerName = true;
private bool $tlsSelfSignedAllowed = false;
private ?string $tlsCertificateAuthorityFile = null;
private ?string $tlsCertificateAuthorityPath = null;
private ?string $tlsClientCertificateFile = null;
private ?string $tlsClientCertificateKeyFile = null;
private ?string $tlsClientCertificateKeyPassphrase = null;
private ?string $tlsAlpn = null;
/**
* The username used for authentication when connecting to the broker.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setUsername(?string $username): ConnectionSettings
{
$copy = clone $this;
$copy->username = $username;
return $copy;
}
public function getUsername(): ?string
{
return $this->username;
}
/**
* The password used for authentication when connecting to the broker.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setPassword(?string $password): ConnectionSettings
{
$copy = clone $this;
$copy->password = $password;
return $copy;
}
public function getPassword(): ?string
{
return $this->password;
}
/**
* Whether to use a blocking socket when publishing messages or not.
* Normally, this setting can be ignored. When publishing large messages with multiple kilobytes in size,
* a blocking socket may be required if the receipt buffer of the broker is not large enough.
*
* Note: This setting has no effect on subscriptions, only on the publishing of messages.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function useBlockingSocket(bool $useBlockingSocket): ConnectionSettings
{
$copy = clone $this;
$copy->useBlockingSocket = $useBlockingSocket;
return $copy;
}
public function shouldUseBlockingSocket(): bool
{
return $this->useBlockingSocket;
}
/**
* The connect timeout is the maximum amount of seconds the client will try to establish
* a socket connection with the broker. The value cannot be less than 1 second.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setConnectTimeout(int $connectTimeout): ConnectionSettings
{
$copy = clone $this;
$copy->connectTimeout = $connectTimeout;
return $copy;
}
public function getConnectTimeout(): int
{
return $this->connectTimeout;
}
/**
* The socket timeout is the maximum amount of idle time in seconds for the socket connection.
* If no data is read or sent for the given amount of seconds, the socket will be closed.
* The value cannot be less than 1 second.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setSocketTimeout(int $socketTimeout): ConnectionSettings
{
$copy = clone $this;
$copy->socketTimeout = $socketTimeout;
return $copy;
}
public function getSocketTimeout(): int
{
return $this->socketTimeout;
}
/**
* The resend timeout is the number of seconds the client will wait before sending a duplicate
* of pending messages without acknowledgement. The value cannot be less than 1 second.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setResendTimeout(int $resendTimeout): ConnectionSettings
{
$copy = clone $this;
$copy->resendTimeout = $resendTimeout;
return $copy;
}
public function getResendTimeout(): int
{
return $this->resendTimeout;
}
/**
* The keep alive interval is the number of seconds the client will wait without sending a message
* until it sends a keep alive signal (ping) to the broker. The value cannot be less than 1 second
* and may not be higher than 65535 seconds. A reasonable value is 10 seconds (the default).
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setKeepAliveInterval(int $keepAliveInterval): ConnectionSettings
{
$copy = clone $this;
$copy->keepAliveInterval = $keepAliveInterval;
return $copy;
}
public function getKeepAliveInterval(): int
{
return $this->keepAliveInterval;
}
/**
* This flag determines whether the client will try to reconnect automatically,
* if it notices a disconnect while sending data.
* The setting cannot be used together with the clean session flag.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setReconnectAutomatically(bool $reconnectAutomatically): ConnectionSettings
{
$copy = clone $this;
$copy->reconnectAutomatically = $reconnectAutomatically;
return $copy;
}
public function shouldReconnectAutomatically(): bool
{
return $this->reconnectAutomatically;
}
/**
* Defines the maximum number of reconnect attempts until the client gives up. This setting
* is only relevant if {@see setReconnectAutomatically()} is set to true.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setMaxReconnectAttempts(int $maxReconnectAttempts): ConnectionSettings
{
$copy = clone $this;
$copy->maxReconnectAttempts = $maxReconnectAttempts;
return $copy;
}
public function getMaxReconnectAttempts(): int
{
return $this->maxReconnectAttempts;
}
/**
* Defines the delay between reconnect attempts in milliseconds.
* This setting is only relevant if {@see setReconnectAutomatically()} is set to true.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setDelayBetweenReconnectAttempts(int $delayBetweenReconnectAttempts): ConnectionSettings
{
$copy = clone $this;
$copy->delayBetweenReconnectAttempts = $delayBetweenReconnectAttempts;
return $copy;
}
public function getDelayBetweenReconnectAttempts(): int
{
return $this->delayBetweenReconnectAttempts;
}
/**
* If the broker should publish a last will message in the name of the client when the client
* disconnects abruptly, this setting defines the topic on which the message will be published.
*
* A last will message will only be published if both this setting as well as the last will
* message are configured.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setLastWillTopic(?string $lastWillTopic): ConnectionSettings
{
$copy = clone $this;
$copy->lastWillTopic = $lastWillTopic;
return $copy;
}
public function getLastWillTopic(): ?string
{
return $this->lastWillTopic;
}
/**
* If the broker should publish a last will message in the name of the client when the client
* disconnects abruptly, this setting defines the message which will be published.
*
* A last will message will only be published if both this setting as well as the last will
* topic are configured.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setLastWillMessage(?string $lastWillMessage): ConnectionSettings
{
$copy = clone $this;
$copy->lastWillMessage = $lastWillMessage;
return $copy;
}
public function getLastWillMessage(): ?string
{
return $this->lastWillMessage;
}
/**
* Determines whether the client has a last will.
*/
public function hasLastWill(): bool
{
return $this->lastWillTopic !== null && $this->lastWillMessage !== null;
}
/**
* The quality of service level the last will message of the client will be published with,
* if it gets triggered.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setLastWillQualityOfService(int $lastWillQualityOfService): ConnectionSettings
{
$copy = clone $this;
$copy->lastWillQualityOfService = $lastWillQualityOfService;
return $copy;
}
public function getLastWillQualityOfService(): int
{
return $this->lastWillQualityOfService;
}
/**
* This flag determines if the last will message of the client will be retained, if it gets
* triggered. Using this setting can be handy to signal that a client is offline by publishing
* a retained offline state in the last will and an online state as first message on connect.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setRetainLastWill(bool $lastWillRetain): ConnectionSettings
{
$copy = clone $this;
$copy->lastWillRetain = $lastWillRetain;
return $copy;
}
public function shouldRetainLastWill(): bool
{
return $this->lastWillRetain;
}
/**
* This flag determines if TLS should be used for the connection. The port which is used to
* connect to the broker must support TLS connections.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setUseTls(bool $useTls): ConnectionSettings
{
$copy = clone $this;
$copy->useTls = $useTls;
return $copy;
}
public function shouldUseTls(): bool
{
return $this->useTls;
}
/**
* This flag determines if the peer certificate is verified, if TLS is used.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsVerifyPeer(bool $tlsVerifyPeer): ConnectionSettings
{
$copy = clone $this;
$copy->tlsVerifyPeer = $tlsVerifyPeer;
return $copy;
}
public function shouldTlsVerifyPeer(): bool
{
return $this->tlsVerifyPeer;
}
/**
* This flag determines if the peer name is verified, if TLS is used.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsVerifyPeerName(bool $tlsVerifyPeerName): ConnectionSettings
{
$copy = clone $this;
$copy->tlsVerifyPeerName = $tlsVerifyPeerName;
return $copy;
}
public function shouldTlsVerifyPeerName(): bool
{
return $this->tlsVerifyPeerName;
}
/**
* This flag determines if self signed certificates of the peer should be accepted.
* Setting this to TRUE implies a security risk and should be avoided for production
* scenarios and public services.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsSelfSignedAllowed(bool $tlsSelfSignedAllowed): ConnectionSettings
{
$copy = clone $this;
$copy->tlsSelfSignedAllowed = $tlsSelfSignedAllowed;
return $copy;
}
public function isTlsSelfSignedAllowed(): bool
{
return $this->tlsSelfSignedAllowed;
}
/**
* The path to a Certificate Authority certificate which is used to verify the peer
* certificate, if TLS is used.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsCertificateAuthorityFile(?string $tlsCertificateAuthorityFile): ConnectionSettings
{
$copy = clone $this;
$copy->tlsCertificateAuthorityFile = $tlsCertificateAuthorityFile;
return $copy;
}
public function getTlsCertificateAuthorityFile(): ?string
{
return $this->tlsCertificateAuthorityFile;
}
/**
* The path to a directory containing Certificate Authority certificates which are
* used to verify the peer certificate, if TLS is used.
*
* Certificate files in this directory must be named by the hash of the certificate,
* ending with ".0" (without quotes). The certificate hash can be retrieved using the
* openssl_x509_parse() function, which returns an array. The hash can be found in the
* array under the key "hash".
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsCertificateAuthorityPath(?string $tlsCertificateAuthorityPath): ConnectionSettings
{
$copy = clone $this;
$copy->tlsCertificateAuthorityPath = $tlsCertificateAuthorityPath;
return $copy;
}
public function getTlsCertificateAuthorityPath(): ?string
{
return $this->tlsCertificateAuthorityPath;
}
/**
* The path to a client certificate file used for authentication, if TLS is used.
*
* The client certificate must be PEM encoded. It may optionally contain the
* certificate chain of issuers. The certificate key can be included in this certificate
* file or in a separate file ({@see ConnectionSettings::setTlsClientCertificateKeyFile()}).
* A passphrase can be configured using {@see ConnectionSettings::setTlsClientCertificateKeyPassphrase()}.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsClientCertificateFile(?string $tlsClientCertificateFile): ConnectionSettings
{
$copy = clone $this;
$copy->tlsClientCertificateFile = $tlsClientCertificateFile;
return $copy;
}
public function getTlsClientCertificateFile(): ?string
{
return $this->tlsClientCertificateFile;
}
/**
* The path to a client certificate key file used for authentication, if TLS is used.
*
* This option requires {@see ConnectionSettings::setTlsClientCertificateFile()}
* to be used as well.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsClientCertificateKeyFile(?string $tlsClientCertificateKeyFile): ConnectionSettings
{
$copy = clone $this;
$copy->tlsClientCertificateKeyFile = $tlsClientCertificateKeyFile;
return $copy;
}
public function getTlsClientCertificateKeyFile(): ?string
{
return $this->tlsClientCertificateKeyFile;
}
/**
* The passphrase used to decrypt the private key of the client certificate,
* which in return is used for authentication, if TLS is used.
*
* This option requires {@see ConnectionSettings::setTlsClientCertificateFile()}
* and {@see ConnectionSettings::setTlsClientCertificateKeyFile()} to be used as well.
*
* Please be aware that your passphrase is not stored in secure memory when using this option.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsClientCertificateKeyPassphrase(?string $tlsClientCertificateKeyPassphrase): ConnectionSettings
{
$copy = clone $this;
$copy->tlsClientCertificateKeyPassphrase = $tlsClientCertificateKeyPassphrase;
return $copy;
}
public function getTlsClientCertificateKeyPassphrase(): ?string
{
return $this->tlsClientCertificateKeyPassphrase;
}
/**
* The TLS ALPN is used to establish a TLS encrypted mqtt connection on port 443,
* which usually is reserved for TLS encrypted HTTP traffic.
*
* @return ConnectionSettings A copy of the original object with the new setting applied.
*/
public function setTlsAlpn(?string $tlsAlpn): ConnectionSettings
{
$copy = clone $this;
$copy->tlsAlpn = $tlsAlpn;
return $copy;
}
public function getTlsAlpn(): ?string
{
return $this->tlsAlpn;
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Contracts;
use PhpMqtt\Client\ConnectionSettings;
use PhpMqtt\Client\Exceptions\ConnectingToBrokerFailedException;
use PhpMqtt\Client\Exceptions\InvalidMessageException;
use PhpMqtt\Client\Exceptions\MqttClientException;
use PhpMqtt\Client\Exceptions\ProtocolViolationException;
use PhpMqtt\Client\Message;
use PhpMqtt\Client\Subscription;
/**
* Implementations of this interface provide message parsing capabilities.
* Services of this type are used by the {@see MqttClient} to implement multiple protocol versions.
*
* @package PhpMqtt\Client\Contracts
*/
interface MessageProcessor
{
/**
* Try to parse a message from the incoming buffer. If a message could be parsed successfully,
* the given message parameter is set to the parsed message and the result is true.
* If no message could be parsed, the result is false and the required bytes parameter indicates
* how many bytes are missing for the message to be complete. If this parameter is set to -1,
* it means we have no (or not yet) knowledge about the required bytes.
*/
public function tryFindMessageInBuffer(string $buffer, int $bufferLength, ?string &$message = null, int &$requiredBytes = -1): bool;
/**
* Parses and validates the given message based on its message type and contents.
* If no valid message could be found in the data, and no further action is required by the caller,
* null is returned.
*
* @throws InvalidMessageException
* @throws ProtocolViolationException
* @throws MqttClientException
*/
public function parseAndValidateMessage(string $message): ?Message;
/**
* Builds a connect message from the given connection settings, taking the protocol
* specifics into account.
*/
public function buildConnectMessage(ConnectionSettings $connectionSettings, bool $useCleanSession = false): string;
/**
* Builds a ping request message.
*/
public function buildPingRequestMessage(): string;
/**
* Builds a ping response message.
*/
public function buildPingResponseMessage(): string;
/**
* Builds a disconnect message.
*/
public function buildDisconnectMessage(): string;
/**
* Builds a subscribe message from the given parameters.
*
* @param Subscription[] $subscriptions
*/
public function buildSubscribeMessage(int $messageId, array $subscriptions, bool $isDuplicate = false): string;
/**
* Builds an unsubscribe message from the given parameters.
*
* @param string[] $topics
*/
public function buildUnsubscribeMessage(int $messageId, array $topics, bool $isDuplicate = false): string;
/**
* Builds a publish message based on the given parameters.
*/
public function buildPublishMessage(
string $topic,
string $message,
int $qualityOfService,
bool $retain,
?int $messageId = null,
bool $isDuplicate = false,
): string;
/**
* Builds a publish acknowledgement for the given message identifier.
*/
public function buildPublishAcknowledgementMessage(int $messageId): string;
/**
* Builds a publish received message for the given message identifier.
*/
public function buildPublishReceivedMessage(int $messageId): string;
/**
* Builds a publish release message for the given message identifier.
*/
public function buildPublishReleaseMessage(int $messageId): string;
/**
* Builds a publish complete message for the given message identifier.
*/
public function buildPublishCompleteMessage(int $messageId): string;
/**
* Handles the connect acknowledgement received from the broker. Exits normally if the
* connection could be established successfully according to the response. Throws an
* exception if the broker responded with an error.
*
* @throws ConnectingToBrokerFailedException
*/
public function handleConnectAcknowledgement(string $message): void;
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Contracts;
use PhpMqtt\Client\ConnectionSettings;
use PhpMqtt\Client\Exceptions\ConfigurationInvalidException;
use PhpMqtt\Client\Exceptions\ConnectingToBrokerFailedException;
use PhpMqtt\Client\Exceptions\DataTransferException;
use PhpMqtt\Client\Exceptions\InvalidMessageException;
use PhpMqtt\Client\Exceptions\MqttClientException;
use PhpMqtt\Client\Exceptions\ProtocolViolationException;
use PhpMqtt\Client\Exceptions\RepositoryException;
/**
* An interface for the MQTT client.
*
* @package PhpMqtt\Client\Contracts
*/
interface MqttClient
{
/**
* Connect to the MQTT broker using the given settings.
* If no custom settings are passed, the client will use the default settings.
* See {@see ConnectionSettings} for more details about the defaults.
*
* @throws ConfigurationInvalidException
* @throws ConnectingToBrokerFailedException
*/
public function connect(?ConnectionSettings $settings = null, bool $useCleanSession = false): void;
/**
* Sends a disconnect message to the broker and closes the socket.
*
* @throws DataTransferException
*/
public function disconnect(): void;
/**
* Returns an indication, whether the client is supposed to be connected already or not.
*
* Note: the result of this method should be used carefully, since we can only detect a
* closed socket once we try to send or receive data. Therefore, this method only gives
* an indication whether the client is in a connected state or not.
*
* This information may be useful in applications where multiple parts use the client.
*/
public function isConnected(): bool;
/**
* Publishes the given message on the given topic. If the additional quality of service
* and retention flags are set, the message will be published using these settings.
*
* @throws DataTransferException
* @throws RepositoryException
*/
public function publish(string $topic, string $message, int $qualityOfService = 0, bool $retain = false): void;
/**
* Subscribe to the given topic with the given quality of service.
*
* The subscription callback is passed the topic as first and the message as second
* parameter. A third parameter indicates whether the received message has been sent
* because it was retained by the broker. A fourth parameter contains matched topic wildcards.
*
* Example:
* ```php
* $mqtt->subscribe(
* '/foo/bar/+',
* function (string $topic, string $message, bool $retained, array $matchedWildcards) use ($logger) {
* $logger->info("Received {retained} message on topic [{topic}]: {message}", [
* 'topic' => $topic,
* 'message' => $message,
* 'retained' => $retained ? 'retained' : 'live'
* ]);
* }
* );
* ```
*
* If no callback is passed, a subscription will still be made. Received messages are delivered only to
* event handlers for received messages though.
*
* @throws DataTransferException
* @throws RepositoryException
*/
public function subscribe(string $topicFilter, ?callable $callback = null, int $qualityOfService = 0): void;
/**
* Unsubscribe from the given topic.
*
* @throws DataTransferException
* @throws RepositoryException
*/
public function unsubscribe(string $topicFilter): void;
/**
* Sets the interrupted signal. Doing so instructs the client to exit the loop, if it is
* actually looping.
*
* Sending multiple interrupt signals has no effect, unless the client exits the loop,
* which resets the signal for another loop.
*/
public function interrupt(): void;
/**
* Runs an event loop that handles messages from the server and calls the registered
* callbacks for published messages.
*
* If the second parameter is provided, the loop will exit as soon as all
* queues are empty. This means there may be no open subscriptions,
* no pending messages as well as acknowledgments and no pending unsubscribe requests.
*
* The third parameter will, if set, lead to a forceful exit after the specified
* amount of seconds, but only if the second parameter is set to true. This basically
* means that if we wait for all pending messages to be acknowledged, we only wait
* a maximum of $queueWaitLimit seconds until we give up. We do not exit after the
* given amount of time if there are open topic subscriptions though.
*
* @throws DataTransferException
* @throws InvalidMessageException
* @throws MqttClientException
* @throws ProtocolViolationException
*/
public function loop(bool $allowSleep = true, bool $exitWhenQueuesEmpty = false, ?int $queueWaitLimit = null): void;
/**
* Runs an event loop iteration that handles messages from the server and calls the registered
* callbacks for published messages. Also resends pending messages and calls loop event handlers.
*
* This method can be used to integrate the MQTT client in another event loop (like ReactPHP or Ratchet).
*
* Note: To ensure the event handlers called by this method will receive the correct elapsed time,
* the caller is responsible to provide the correct starting time of the loop as returned by `microtime(true)`.
*
* @throws DataTransferException
* @throws InvalidMessageException
* @throws MqttClientException
* @throws ProtocolViolationException
*/
public function loopOnce(float $loopStartedAt, bool $allowSleep = false, int $sleepMicroseconds = 100000): void;
/**
* Returns the host used by the client to connect to.
*/
public function getHost(): string;
/**
* Returns the port used by the client to connect to.
*/
public function getPort(): int;
/**
* Returns the identifier used by the client.
*/
public function getClientId(): string;
/**
* Returns the total number of received bytes, across reconnects.
*/
public function getReceivedBytes(): int;
/**
* Returns the total number of sent bytes, across reconnects.
*/
public function getSentBytes(): int;
/**
* Registers a loop event handler which is called each iteration of the loop.
* This event handler can be used for example to interrupt the loop under
* certain conditions.
*
* The loop event handler is passed the MQTT client instance as first and
* the elapsed time which the loop is already running for as second
* parameter. The elapsed time is a float containing seconds.
*
* Example:
* ```php
* $mqtt->registerLoopEventHandler(function (
* MqttClient $mqtt,
* float $elapsedTime
* ) use ($logger) {
* $logger->info("Running for [{$elapsedTime}] seconds already.");
* });
* ```
*
* Multiple event handlers can be registered at the same time.
*/
public function registerLoopEventHandler(\Closure $callback): MqttClient;
/**
* Unregisters a loop event handler which prevents it from being called
* in the future.
*
* This does not affect other registered event handlers. It is possible
* to unregister all registered event handlers by passing null as callback.
*/
public function unregisterLoopEventHandler(?\Closure $callback = null): MqttClient;
/**
* Registers a loop event handler which is called when a message is published.
*
* The loop event handler is passed the MQTT client as first, the topic as
* second and the message as third parameter. As fourth parameter, the
* message identifier will be passed. The QoS level as well as the retained
* flag will also be passed as fifth and sixth parameters.
*
* Example:
* ```php
* $mqtt->registerPublishEventHandler(function (
* MqttClient $mqtt,
* string $topic,
* string $message,
* int $messageId,
* int $qualityOfService,
* bool $retain
* ) use ($logger) {
* $logger->info("Received message on topic [{$topic}]: {$message}");
* });
* ```
*
* Multiple event handlers can be registered at the same time.
*/
public function registerPublishEventHandler(\Closure $callback): MqttClient;
/**
* Unregisters a publish event handler which prevents it from being called
* in the future.
*
* This does not affect other registered event handlers. It is possible
* to unregister all registered event handlers by passing null as callback.
*/
public function unregisterPublishEventHandler(?\Closure $callback = null): MqttClient;
/**
* Registers an event handler which is called when a message is received from the broker.
*
* The message received event handler is passed the MQTT client as first, the topic as
* second and the message as third parameter. As fourth parameter, the QoS level will be
* passed and the retained flag as fifth.
*
* Example:
* ```php
* $mqtt->registerReceivedMessageEventHandler(function (
* MqttClient $mqtt,
* string $topic,
* string $message,
* int $qualityOfService,
* bool $retained
* ) use ($logger) {
* $logger->info("Received message on topic [{$topic}]: {$message}");
* });
* ```
*
* Multiple event handlers can be registered at the same time.
*/
public function registerMessageReceivedEventHandler(\Closure $callback): MqttClient;
/**
* Unregisters a message received event handler which prevents it from being called in the future.
*
* This does not affect other registered event handlers. It is possible
* to unregister all registered event handlers by passing null as callback.
*/
public function unregisterMessageReceivedEventHandler(?\Closure $callback = null): MqttClient;
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Contracts;
use DateTime;
use PhpMqtt\Client\Exceptions\PendingMessageAlreadyExistsException;
use PhpMqtt\Client\Exceptions\PendingMessageNotFoundException;
use PhpMqtt\Client\Exceptions\RepositoryException;
use PhpMqtt\Client\PendingMessage;
use PhpMqtt\Client\Subscription;
/**
* Implementations of this interface provide storage capabilities to an MQTT client.
*
* Services of this type have three primary goals:
* 1. Providing and keeping track of message identifiers, since they must be unique
* within the message flow (i.e. there may not be duplicates of different messages
* at the same time).
* 2. Storing and keeping track of subscriptions, which is especially necessary in case
* of persisted sessions.
* 3. Storing and keeping track of pending messages (i.e. sent messages, which have not
* been acknowledged yet by the broker).
*
* @package PhpMqtt\Client\Contracts
*/
interface Repository
{
/**
* Re-initializes the repository by deleting all persisted data and restoring the original state,
* which was given when the repository was first created. This is used when a clean session
* is requested by a client during connection.
*/
public function reset(): void;
/**
* Returns a new message id. The message id might have been used before,
* but it is currently not being used (i.e. in a resend queue).
*
* @throws RepositoryException
*/
public function newMessageId(): int;
/**
* Returns the number of pending outgoing messages.
*/
public function countPendingOutgoingMessages(): int;
/**
* Gets a pending outgoing message with the given message identifier, if found.
*/
public function getPendingOutgoingMessage(int $messageId): ?PendingMessage;
/**
* Gets a list of pending outgoing messages last sent before the given date time.
*
* If date time is `null`, all pending messages are returned.
*
* The messages are returned in the same order they were added to the repository.
*
* @return PendingMessage[]
*/
public function getPendingOutgoingMessagesLastSentBefore(?DateTime $dateTime = null): array;
/**
* Adds a pending outgoing message to the repository.
*
* @throws PendingMessageAlreadyExistsException
*/
public function addPendingOutgoingMessage(PendingMessage $message): void;
/**
* Marks an existing pending outgoing published message as received in the repository.
*
* If the message does not exists, an exception is thrown,
* otherwise `true` is returned if the message was marked as received, and `false`
* in case it was already marked as received.
*
* @throws PendingMessageNotFoundException
*/
public function markPendingOutgoingPublishedMessageAsReceived(int $messageId): bool;
/**
* Removes a pending outgoing message from the repository.
*
* If a pending message with the given identifier is found and
* successfully removed from the repository, `true` is returned.
* Otherwise `false` will be returned.
*/
public function removePendingOutgoingMessage(int $messageId): bool;
/**
* Returns the number of pending incoming messages.
*/
public function countPendingIncomingMessages(): int;
/**
* Gets a pending incoming message with the given message identifier, if found.
*/
public function getPendingIncomingMessage(int $messageId): ?PendingMessage;
/**
* Adds a pending outgoing message to the repository.
*
* @throws PendingMessageAlreadyExistsException
*/
public function addPendingIncomingMessage(PendingMessage $message): void;
/**
* Removes a pending incoming message from the repository.
*
* If a pending message with the given identifier is found and
* successfully removed from the repository, `true` is returned.
* Otherwise `false` will be returned.
*/
public function removePendingIncomingMessage(int $messageId): bool;
/**
* Returns the number of registered subscriptions.
*/
public function countSubscriptions(): int;
/**
* Adds a subscription to the repository.
*/
public function addSubscription(Subscription $subscription): void;
/**
* Gets all subscriptions matching the given topic.
*
* @return Subscription[]
*/
public function getSubscriptionsMatchingTopic(string $topicName): array;
/**
* Removes the subscription with the given topic filter from the repository.
*
* Returns `true` if a topic subscription existed and has been removed.
* Otherwise, `false` is returned.
*/
public function removeSubscription(string $topicFilter): bool;
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an MQTT client is not connected to a broker and tries
* to perform an action which requires a connection (e.g. publish or subscribe).
*
* @package PhpMqtt\Client\Exceptions
*/
class ClientNotConnectedToBrokerException extends DataTransferException
{
public const EXCEPTION_CONNECTION_LOST = 0300;
/**
* ClientNotConnectedToBrokerException constructor.
*/
public function __construct(string $error)
{
parent::__construct(self::EXCEPTION_CONNECTION_LOST, $error);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an MQTT client has been misconfigured or wrong connection
* settings are being used.
*
* @package PhpMqtt\Client\Exceptions
*/
class ConfigurationInvalidException extends MqttClientException
{
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an MQTT client could not connect to the broker.
*
* @package PhpMqtt\Client\Exceptions
*/
class ConnectingToBrokerFailedException extends MqttClientException
{
public const EXCEPTION_CONNECTION_FAILED = 0001;
public const EXCEPTION_CONNECTION_PROTOCOL_VERSION = 0002;
public const EXCEPTION_CONNECTION_IDENTIFIER_REJECTED = 0003;
public const EXCEPTION_CONNECTION_BROKER_UNAVAILABLE = 0004;
public const EXCEPTION_CONNECTION_INVALID_CREDENTIALS = 0005;
public const EXCEPTION_CONNECTION_UNAUTHORIZED = 0006;
public const EXCEPTION_CONNECTION_SOCKET_ERROR = 1000;
public const EXCEPTION_CONNECTION_TLS_ERROR = 2000;
/**
* ConnectingToBrokerFailedException constructor.
*/
public function __construct(
int $code,
string $error,
private ?string $connectionErrorCode = null,
private ?string $connectionErrorMessage = null,
)
{
parent::__construct(
sprintf('[%s] Establishing a connection to the MQTT broker failed: %s', $code, $error),
$code
);
}
/**
* Retrieves the connection error code.
*/
public function getConnectionErrorCode(): ?string
{
return $this->connectionErrorCode;
}
/**
* Retrieves the connection error message.
*/
public function getConnectionErrorMessage(): ?string
{
return $this->connectionErrorMessage;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an MQTT client encountered an error while transferring data.
*
* @package PhpMqtt\Client\Exceptions
*/
class DataTransferException extends MqttClientException
{
public const EXCEPTION_TX_DATA = 0101;
public const EXCEPTION_RX_DATA = 0102;
/**
* DataTransferException constructor.
*/
public function __construct(int $code, string $error)
{
parent::__construct(
sprintf('[%s] Transferring data over socket failed: %s', $code, $error),
$code
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an MQTT client encounters an invalid message.
*
* @package PhpMqtt\Client\Exceptions
*/
class InvalidMessageException extends MqttClientException
{
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an MQTT client error occurs.
*
* @package PhpMqtt\Client\Exceptions
*/
class MqttClientException extends \Exception
{
/**
* MqttClientException constructor.
*/
public function __construct(string $message = '', int $code = 0, ?\Throwable $parentException = null)
{
if (empty($message)) {
parent::__construct(
sprintf('[%s] The MQTT client encountered an error.', $code),
$code,
$parentException
);
} else {
parent::__construct($message, $code, $parentException);
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if a pending message with the same packet identifier is still pending.
*
* @package PhpMqtt\Client\Exceptions
*/
class PendingMessageAlreadyExistsException extends RepositoryException
{
/**
* PendingMessageAlreadyExistsException constructor.
*/
public function __construct(int $messageId)
{
parent::__construct(sprintf('A pending message with the message identifier [%s] exists already.', $messageId));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if a pending message with the same packet identifier is not found.
*
* @package PhpMqtt\Client\Exceptions
*/
class PendingMessageNotFoundException extends RepositoryException
{
/**
* PendingMessageNotFoundException constructor.
*/
public function __construct(int $messageId)
{
parent::__construct(sprintf('No pending message with the message identifier [%s].', $messageId));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an invalid MQTT version is given.
*
* @package PhpMqtt\Client\Exceptions
*/
class ProtocolNotSupportedException extends MqttClientException
{
/**
* ProtocolNotSupportedException constructor.
*/
public function __construct(string $protocol)
{
parent::__construct(sprintf('The given protocol version [%s] is not supported.', $protocol));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace PhpMqtt\Client\Exceptions;
/**
* Exception to be thrown if an MQTT client encountered a protocol violation.
*
* @package PhpMqtt\Client\Exceptions
*/
class ProtocolViolationException extends MqttClientException
{
/**
* ProtocolViolationException constructor.
*/
public function __construct(string $error)
{
parent::__construct(sprintf('Protocol violation: %s.', $error));
}
}

Some files were not shown because too many files have changed in this diff Show More