Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9ffaa942 | ||
|
|
e0e6e28dcc | ||
|
|
616b23ce77 | ||
|
|
1492dcf71f | ||
|
|
b38be8366c | ||
|
|
c2bcd7be22 | ||
|
|
6daa0a6f49 | ||
|
|
73450a17c1 | ||
|
|
8ebfcb6061 | ||
|
|
b6896c60e5 | ||
|
|
d733e26f91 | ||
|
|
4e369d887b | ||
|
|
e8e3c97ccd | ||
|
|
8ebf11d7e4 | ||
|
|
3d84e61ed1 | ||
|
|
0137877439 | ||
|
|
46c653eb21 | ||
|
|
2d61f9bab6 | ||
|
|
e9d2ec64bb | ||
|
|
8a0637eddc | ||
|
|
d84a2989d4 | ||
|
|
6bdd5faa7a | ||
|
|
b495fc9ab0 | ||
|
|
2c0e62bbae | ||
|
|
cd498c8c3a | ||
|
|
0961b23b8d | ||
|
|
3dffc82f1e | ||
|
|
68db5fe7b2 | ||
|
|
596d2f68c2 | ||
|
|
a1e5baacac | ||
|
|
cc0e4c19c8 | ||
|
|
5ab18c7b46 | ||
|
|
92d782e6c5 |
+19
-6
@@ -1,7 +1,3 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -15,19 +11,36 @@ Thumbs.db
|
|||||||
|
|
||||||
# Cache & Storage
|
# Cache & Storage
|
||||||
.cache/
|
.cache/
|
||||||
.sass-cache/
|
|
||||||
admin/storage/cache/
|
admin/storage/cache/
|
||||||
admin/storage/geoip/
|
admin/storage/geoip/
|
||||||
admin/storage/stats.json
|
admin/storage/stats.json
|
||||||
|
var/
|
||||||
|
|
||||||
|
# Runtime-compiled theme assets (generated by scssphp, read-only)
|
||||||
|
themes/*/assets/css_compiled/
|
||||||
|
|
||||||
|
# Runtime-compiled theme assets
|
||||||
|
public/themes/
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
.bak/
|
||||||
|
|
||||||
# Local configuration & credentials
|
# Local configuration & credentials
|
||||||
config.json
|
config.json
|
||||||
|
config.*.json
|
||||||
|
!config.json.example
|
||||||
admin/config/admin.json
|
admin/config/admin.json
|
||||||
|
|
||||||
# No content
|
# No content (per-domain content dirs included)
|
||||||
content/
|
content/
|
||||||
|
content-*/
|
||||||
!content/.gitkeep
|
!content/.gitkeep
|
||||||
|
|
||||||
|
# Test results
|
||||||
|
pentest_results.*
|
||||||
|
accessibility-test-results.*
|
||||||
|
enhanced-test-results.*
|
||||||
|
cli/test/functional/test-report*.md
|
||||||
|
cli/test/functional/function-test.md
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# Disable directory listing globally
|
|
||||||
Options -Indexes
|
|
||||||
|
|
||||||
# Security - Block access to entire application
|
|
||||||
<Files ~ "^\.">
|
|
||||||
Order allow,deny
|
|
||||||
Deny from all
|
|
||||||
</Files>
|
|
||||||
|
|
||||||
<FilesMatch "\.(php|ini|log|conf|config|md|map)$">
|
|
||||||
Order allow,deny
|
|
||||||
Deny from all
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
# Block access to backup files
|
|
||||||
<FilesMatch "\.(backup|bak|old|orig|swp|save)$">
|
|
||||||
Order allow,deny
|
|
||||||
Deny from all
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
# Block access to all application files
|
|
||||||
<IfModule mod_authz_core.c>
|
|
||||||
Require all denied
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
# Directory protection - Block all access
|
|
||||||
<Directory />
|
|
||||||
Order allow,deny
|
|
||||||
Deny from all
|
|
||||||
</Directory>
|
|
||||||
|
|
||||||
# Only allow access to public directory
|
|
||||||
<Directory "public">
|
|
||||||
Order allow,deny
|
|
||||||
Allow from all
|
|
||||||
Require all granted
|
|
||||||
</Directory>
|
|
||||||
|
|
||||||
# Set default directory to public
|
|
||||||
DirectoryIndex public/index.php
|
|
||||||
|
|
||||||
# Redirect root to public directory
|
|
||||||
<IfModule mod_rewrite.c>
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteBase /
|
|
||||||
|
|
||||||
# Redirect root to public
|
|
||||||
RewriteRule ^$ public/ [L]
|
|
||||||
|
|
||||||
# Redirect all other requests to public
|
|
||||||
RewriteCond %{REQUEST_URI} !^/public/
|
|
||||||
RewriteRule ^(.*)$ public/$1 [L]
|
|
||||||
</IfModule>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# 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 cms/router.php` (router nodig voor clean URLs)
|
|
||||||
- **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/
|
|
||||||
├── cms/ # Core CMS 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
|
|
||||||
│ └── router.php # PHP dev server router
|
|
||||||
├── admin/ # 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
|
|
||||||
│ │ ├── content-dir-form.php
|
|
||||||
│ │ ├── config.php
|
|
||||||
│ │ ├── plugins.php
|
|
||||||
│ │ ├── plugin-config.php
|
|
||||||
│ │ └── users.php
|
|
||||||
│ └── storage/logs/ # Admin logs
|
|
||||||
├── cli/ # CLI scripts & tests
|
|
||||||
│ └── test/
|
|
||||||
│ ├── accessibility.sh # WCAG 2.1 AA test suite
|
|
||||||
│ ├── enhanced-suite.sh # Enhanced test suite
|
|
||||||
│ ├── functional/ # Functionele testen
|
|
||||||
│ └── pentest/ # Penetratietesten
|
|
||||||
├── 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)
|
|
||||||
├── docs/ # Documentatie
|
|
||||||
├── config.json # Site configuratie
|
|
||||||
└── 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 CMS logic in `cms/core/class/CodePressCMS.php`
|
|
||||||
- Bootstrap/requires in `cms/core/index.php`
|
|
||||||
- Configuration loaded from `config.json` via `cms/core/config.php`
|
|
||||||
- Public website entry point: `public/index.php`
|
|
||||||
- Admin entry point + routing: `public/admin.php`
|
|
||||||
- Admin authenticatie: `admin/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
|
|
||||||
- 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/config/admin.json`
|
|
||||||
- **Routing**: Via `?route=` parameter in `public/admin.php`
|
|
||||||
- **Routes**: `login`, `logout`, `dashboard`, `content`, `content-edit`, `content-new`, `content-delete`, `config`, `plugins`, `plugins-new`, `plugins-edit`, `plugins-config`, `plugins-toggle`, `plugins-delete`, `users`
|
|
||||||
- **Auth**: Session-based. `AdminAuth` class handelt login, logout, CSRF, brute-force lockout af
|
|
||||||
- **Templates**: Pure PHP templates in `admin/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!
|
|
||||||
- **Examples**:
|
|
||||||
- `nl.test.md` → display as "Test" (not content title)
|
|
||||||
- `nl.test/` directory → display as "Test" (not H1 content)
|
|
||||||
- `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**: 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/config/admin.json` bevat wachtwoord-hashes. Niet committen met echte productie-wachtwoorden.
|
|
||||||
+293
-155
@@ -1,217 +1,355 @@
|
|||||||
# CodePress CMS
|
# CodePress CMS
|
||||||
|
|
||||||
**[🇳🇱 Nederlands](README.md) | [🇬🇧 English](#)**
|
**[🇳🇱 Dutch](README.md) | [🇬🇧 English](#)**
|
||||||
|
|
||||||
A lightweight, file-based content management system built with PHP.
|
A lightweight, file-based content management system built with PHP (≥8.0).
|
||||||
|
|
||||||
**Version:** 1.5.0 | **License:** AGPL v3 / Commercial
|
**Version:** 2.6.1 | **License:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 📝 **Multi-format Content** - Supports Markdown, PHP and HTML files
|
- 📝 **Multi-format Content** - Markdown, PHP and HTML files
|
||||||
- 🧭 **Dynamic Navigation** - Automatic menu generation with dropdowns
|
- 🧭 **Dynamic Navigation** - Automatic menu generation
|
||||||
- 🌍 **Multi-language** - Dutch and English with automatic detection
|
- 🌍 **Multi-language** - NL/EN/DE support
|
||||||
- 🔍 **Search Functionality** - Full-text search through all content
|
- 🔍 **Search** - Full-text search
|
||||||
- 🧭 **Breadcrumb Navigation** - Intuitive navigation paths with sidebar toggle
|
- 📱 **Responsive** - Bootstrap 5 themes
|
||||||
- 🔗 **Auto-linking** - Automatic links between pages
|
- 🔒 **Security** - 100/100 pentest score
|
||||||
- 📱 **Responsive Design** - Works perfectly on all devices
|
- 🛡️ **Admin Console** - CodeMirror editor, media management, themes, plugins
|
||||||
- ⚙️ **JSON Configuration** - Easy configuration via JSON
|
- 👥 **User Roles** - Admin, Content Manager, BI Manager, Site Admin
|
||||||
- 🎨 **Themes** - Customizable themes with colors and backgrounds
|
- 📊 **Analytics** - Visitor statistics with GeoIP
|
||||||
- 🔒 **Security** - Secure content management (100/100 security score)
|
- 🤖 **BotGuard** - Bot/AI protection
|
||||||
- 🛡️ **Admin Console** - Built-in admin panel with CodeMirror editor, media browser, theme manager, and plugin configuration
|
- 📈 **Logging** - Comprehensive logging system
|
||||||
|
- 🔌 **Plugin System** - Sidebar plugins with own CSS/SCSS, Twig templates
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php -S localhost:8080 -t public
|
# Install dependencies
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# Start server with router for clean URLs (local only)
|
||||||
|
php -S localhost:8080 cms/router.php
|
||||||
```
|
```
|
||||||
Visit `http://localhost:8080` in your browser.
|
|
||||||
Admin panel: `http://localhost:8080/admin.php` (login: `admin` / `admin`)
|
**Website:** `http://localhost:8080`
|
||||||
|
**Admin:** `http://localhost:8080/admin` (login: `admin` / `admin`)
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- **PHP** ≥ 8.0 with extensions: `json`, `mbstring`
|
||||||
|
- **Composer** (PHP dependency manager)
|
||||||
|
- Web server: **Apache 2.4+** with `mod_rewrite` or **Nginx** with PHP-FPM
|
||||||
|
- Optional: `opcache` (recommended for performance), `git` (for content versioning), `zip` extension (for ZIP backup/restore)
|
||||||
|
|
||||||
|
### Step 1 — Code and dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url> codepress
|
||||||
|
cd codepress
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.json.example config.json
|
||||||
|
cp admin/config/admin.json.example admin/config/admin.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `config.json` with your site title, language and plugins. Change the admin password in `admin/config/admin.json` (default `admin`/`admin`).
|
||||||
|
|
||||||
|
### Step 3a — Apache 2.4+
|
||||||
|
|
||||||
|
The webroot is the `public/` directory. Example vhost (`/etc/apache2/sites-available/codepress.conf`):
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName example.com
|
||||||
|
DocumentRoot /var/www/codepress/public
|
||||||
|
|
||||||
|
<Directory /var/www/codepress/public>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/codepress_error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/codepress_access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
Required Apache modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo a2enmod rewrite headers
|
||||||
|
sudo systemctl restart apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
- `mod_rewrite` — for clean URLs (`/nl/page`) and asset-serving
|
||||||
|
- `mod_headers` — for security headers
|
||||||
|
- `AllowOverride All` — so the `.htaccess` in `public/` is applied
|
||||||
|
|
||||||
|
### Step 3b — Nginx
|
||||||
|
|
||||||
|
Example server block (`/etc/nginx/sites-available/codepress`):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name example.com;
|
||||||
|
root /var/www/codepress/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
# Clean URLs: language-prefixed pages
|
||||||
|
location ~ ^/(nl|en|de)(/(.+))?$ {
|
||||||
|
try_files $uri /index.php?lang=$1&page=$2;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin routes
|
||||||
|
location /admin {
|
||||||
|
try_files $uri /admin.php?$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asset-serving via asset.php (themes/plugins/admin outside webroot)
|
||||||
|
location ~ ^/(themes|plugins)/([^/]+)/assets/(.+)$ {
|
||||||
|
try_files $uri /asset.php;
|
||||||
|
}
|
||||||
|
location ~ ^/admin/assets/(.+)$ {
|
||||||
|
try_files $uri /asset.php;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP via FPM
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security: block access to sensitive directories
|
||||||
|
location ~ ^/(content|cms|admin/src|admin/config|admin/storage|var|vendor)/ {
|
||||||
|
deny all;
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.(git|htaccess) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Nginx does not use `.htaccess`. Security headers must be set in the Nginx config:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Directory permissions
|
||||||
|
|
||||||
|
Make sure the web server has write access to the runtime directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chown -R www-data:www-data var/ admin/storage/ content/
|
||||||
|
chmod -R 755 .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — Test
|
||||||
|
|
||||||
|
Open the website in your browser. With an empty content directory you'll see a welcome page. The admin console is available at `/admin` (login `admin`/`admin`).
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
See **[guide/](guide/)** for extensive documentation per role:
|
||||||
|
|
||||||
|
| Role | Guide |
|
||||||
|
|------|-------|
|
||||||
|
| 📝 Content Editor | [Content Manager](guide/en/content-beheerder.md) |
|
||||||
|
| ⚙️ Administrator | [Admin Manager](guide/en/admin-beheerder.md) |
|
||||||
|
| 🎨 Theme Developer | [Theme Developer](guide/en/theme-developer.md) |
|
||||||
|
| 💻 Developer | [CodePress Developer](guide/en/codepress-developer.md) |
|
||||||
|
|
||||||
|
Each guide has sub-topics in separate folders with sidebar navigation.
|
||||||
|
|
||||||
|
## 👥 User Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|------|------------|
|
||||||
|
| **Admin** | Full access (everything) |
|
||||||
|
| **Content Manager** | Content management, guide |
|
||||||
|
| **BI Manager** | Statistics, logs, guide |
|
||||||
|
| **Site Admin** | Theme, plugins, statistics, logs, update, guide |
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
codepress/
|
codepress/
|
||||||
├── cms/ # Core CMS engine
|
├── cms/ # Core CMS engine
|
||||||
│ ├── core/
|
│ ├── core/class/ # CMS classes (CodePressCMS, ThemeManager, etc.)
|
||||||
│ │ ├── class/
|
│ ├── core/plugin/ # Plugin system (PluginManager, CMSAPI)
|
||||||
│ │ │ ├── CodePressCMS.php # Main CMS class
|
│ └── router.php # PHP dev server router (clean URLs)
|
||||||
│ │ │ ├── Logger.php # Logging system
|
├── language/ # Translation files (nl/, en/, de/ — each with site.php + admin.php)
|
||||||
│ │ │ ├── SimpleTemplate.php # Mustache-style template engine
|
├── admin/ # Admin console
|
||||||
│ │ │ ├── Cache.php
|
│ ├── config/ # Admin configuration (admin.json)
|
||||||
│ │ │ ├── AssetManager.php
|
│ ├── src/AdminAuth.php # Authentication, roles, permissions
|
||||||
│ │ │ ├── SearchEngine.php
|
│ ├── static/ # Static files (404.html)
|
||||||
│ │ │ ├── ContentSecurityPolicy.php
|
│ ├── storage/ # Logs, cache, geoip
|
||||||
│ │ │ └── ...
|
│ └── theme/default/ # Admin theme
|
||||||
│ │ ├── plugin/
|
│ ├── assets/ # CSS, JS, fonts, codemirror
|
||||||
│ │ │ ├── PluginManager.php # Plugin loader
|
│ ├── views/ # Twig templates (layouts, pages)
|
||||||
│ │ │ └── CMSAPI.php # Plugin API
|
│ └── theme.json # Admin theme configuration
|
||||||
│ │ ├── config.php # Configuration loader
|
├── themes/ # Website themes
|
||||||
│ │ └── index.php # Bootstrap (autoloader)
|
│ ├── default/ # Default theme
|
||||||
│ ├── lang/ # Language files (nl.php, en.php)
|
│ │ ├── theme.json # Layout mapping, colors
|
||||||
│ ├── templates/ # Mustache templates
|
│ │ ├── base.twig # Main layout
|
||||||
│ │ ├── layout.mustache
|
│ │ ├── *.twig # Layout templates
|
||||||
│ │ ├── assets/ (header, navigation, footer)
|
│ │ ├── partials/ # Header, navigation, footer
|
||||||
│ │ ├── markdown_content.mustache
|
│ │ └── assets/ # SCSS, CSS, JS, img
|
||||||
│ │ ├── php_content.mustache
|
├── plugins/ # Plugins
|
||||||
│ │ └── html_content.mustache
|
│ ├── HTMLBlock/ # Example sidebar plugin
|
||||||
│ └── router.php # PHP dev server router
|
│ └── Navigation/ # Essential navigation plugin (protected)
|
||||||
├── admin/ # Admin panel
|
│ ├── Navigation.php # Plugin code
|
||||||
│ ├── config/
|
│ ├── plugin.json # Plugin metadata
|
||||||
│ │ ├── app.php # Admin configuration
|
│ ├── assets/scss/ # Plugin SCSS source
|
||||||
│ │ └── admin.json # Users & security
|
│ └── assets/css/ # Plugin CSS
|
||||||
│ ├── src/
|
├── content/ # Website content (.md, .php, .html)
|
||||||
│ │ └── AdminAuth.php # Authentication (sessions, bcrypt, CSRF)
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── login.php
|
|
||||||
│ │ ├── layout.php
|
|
||||||
│ │ └── pages/ (dashboard, content, content-edit, content-new,
|
|
||||||
│ │ content-dir-form, content-move-form, config, plugins,
|
|
||||||
│ │ plugins-edit, plugins-new, plugin-config, theme, media, users)
|
|
||||||
│ └── storage/logs/
|
|
||||||
├── cli/test/ # CLI scripts & tests
|
|
||||||
├── plugins/ # CMS plugins (HTMLBlock, MQTTTracker)
|
|
||||||
├── public/ # Web root
|
├── public/ # Web root
|
||||||
│ ├── assets/
|
|
||||||
│ │ ├── css/ (Bootstrap, styles, editor.css)
|
|
||||||
│ │ ├── js/ (Bootstrap, app.js, editor-toolbar.js)
|
|
||||||
│ │ └── codemirror/ (CodeMirror editor + modes)
|
|
||||||
│ ├── index.php # Website entry point
|
│ ├── index.php # Website entry point
|
||||||
│ ├── admin.php # Admin entry point + router
|
│ └── admin.php # Admin entry point + routing
|
||||||
│ └── themes/ # Uploaded theme backgrounds
|
├── guide/ # Documentation (nl/en)
|
||||||
├── themes/ # Theme configurations
|
│ ├── nl/ # Dutch guides
|
||||||
│ ├── default/theme.json
|
│ └── en/ # English guides
|
||||||
│ └── test/theme.json
|
├── cli/test/ # Test suites
|
||||||
├── content/ # Content files
|
├── var/ # Cache (twig)
|
||||||
├── guide/ # Manuals (nl/en)
|
├── config.json # Site configuration
|
||||||
├── docs/ # Documentation
|
├── composer.json # PHP dependencies
|
||||||
└── config.json # Site configuration
|
└── version.php # Version information
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ Configuration
|
||||||
|
|
||||||
### Basic Configuration (`config.json`)
|
### config.json
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"site_title": "CodePress",
|
"site_title": "CodePress",
|
||||||
"content_dir": "content",
|
|
||||||
"templates_dir": "cms/templates",
|
|
||||||
"default_page": "index",
|
|
||||||
"active_theme": "default",
|
"active_theme": "default",
|
||||||
|
"default_page": "auto",
|
||||||
"language": {
|
"language": {
|
||||||
"default": "nl",
|
"default": "nl",
|
||||||
"available": ["nl", "en"]
|
"available": ["nl", "en"]
|
||||||
},
|
},
|
||||||
"seo": {
|
"enabled_plugins": ["HTMLBlock", "Navigation"],
|
||||||
"description": "CodePress CMS - Lightweight file-based content management system",
|
|
||||||
"keywords": "cms, php, content management, file-based"
|
|
||||||
},
|
|
||||||
"author": {
|
|
||||||
"name": "E. Noorlander",
|
|
||||||
"website": "https://noorlander.info"
|
|
||||||
},
|
|
||||||
"features": {
|
"features": {
|
||||||
"auto_link_pages": true,
|
|
||||||
"search_enabled": true,
|
"search_enabled": true,
|
||||||
"breadcrumbs_enabled": true
|
"breadcrumbs_enabled": true
|
||||||
}
|
},
|
||||||
|
"security": {
|
||||||
|
"block_ai_bots": true,
|
||||||
|
"rate_limit_enabled": true
|
||||||
|
},
|
||||||
|
"analytics": { "enabled": true },
|
||||||
|
"logging": { "enabled": true }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Content Types
|
## 🔧 Dependencies
|
||||||
|
|
||||||
### Markdown (.md)
|
- **PHP ≥8.0** with extensions: json, mbstring
|
||||||
- Auto-linking between pages
|
- **Composer** packages:
|
||||||
- GitHub Flavored Markdown via `league/commonmark`
|
- twig/twig (templating)
|
||||||
- Automatic title extraction
|
- scssphp/scssphp (SCSS compilation)
|
||||||
- Multi-language with `en.page.md` and `nl.page.md`
|
- league/commonmark (Markdown with HeadingPermalinks)
|
||||||
|
- maxmind-db/reader (GeoIP)
|
||||||
|
|
||||||
### PHP (.php)
|
## 🔐 Security
|
||||||
- Full PHP support
|
|
||||||
- Dynamic content generation
|
|
||||||
|
|
||||||
### HTML (.html)
|
- ✅ XSS prevention (htmlspecialchars)
|
||||||
- Static HTML pages
|
- ✅ CSRF tokens (admin forms)
|
||||||
- Bootstrap components
|
- ✅ Path traversal prevention (realpath checks)
|
||||||
|
- ✅ Secure cookies (HttpOnly, SameSite)
|
||||||
|
- ✅ Security headers (X-Frame-Options, CSP)
|
||||||
|
- ✅ Bot/AI protection (BotGuard)
|
||||||
|
- ✅ Rate limiting per IP
|
||||||
|
- ✅ Role-based access control (RBAC)
|
||||||
|
|
||||||
## 🛡️ Admin Console
|
## 🔌 Plugins
|
||||||
|
|
||||||
CodePress includes a built-in admin panel for managing your website.
|
### Plugin structure
|
||||||
|
|
||||||
**Access:** `/admin.php` | **Default login:** `admin` / `admin`
|
```
|
||||||
|
plugins/MyPlugin/
|
||||||
### Modules
|
├── MyPlugin.php # Plugin code (name = plugin name)
|
||||||
- **Dashboard** - Overview with statistics and quick actions
|
├── plugin.json # Plugin metadata
|
||||||
- **Content** - Browse, create, edit, rename, move, and delete files
|
├── assets/scss/ # Plugin SCSS source
|
||||||
- **CodeMirror Editor** - Syntax highlighting with toolbar (bold, italic, heading, link, image, list, media)
|
└── assets/css/ # Plugin CSS (after compilation)
|
||||||
- **Media Browser** - Upload and insert images/video/audio with size prompt
|
|
||||||
- **Configuration** - Edit `config.json` with JSON validation
|
|
||||||
- **Themes** - Create, activate, edit, delete themes with background upload
|
|
||||||
- **Plugins** - Overview, install, configure, and toggle
|
|
||||||
- **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 via `realpath()` + prefix-check
|
|
||||||
- Session timeout (30 min)
|
|
||||||
- Security headers: CSP, X-Frame-Options, X-Content-Type-Options
|
|
||||||
|
|
||||||
> **Important:** Change the default password immediately after installation via Users.
|
|
||||||
|
|
||||||
## 🎨 Themes
|
|
||||||
|
|
||||||
Themes are stored in `themes/name/theme.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "default",
|
|
||||||
"label": "Standard",
|
|
||||||
"header_color": "#0a369d",
|
|
||||||
"header_font_color": "#ffffff",
|
|
||||||
"navigation_color": "#2754b4",
|
|
||||||
"navigation_font_color": "#ffffff",
|
|
||||||
"sidebar_background": "#f8f9fa",
|
|
||||||
"sidebar_border": "#dee2e6",
|
|
||||||
"background_image": "/themes/default_bg.jpg"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌍 Multi-language Support
|
### Essential plugins
|
||||||
|
|
||||||
- File naming convention: `nl.[page].md` and `en.[page].md`
|
The **Navigation** plugin is an essential plugin and cannot be disabled, edited, or deleted. This plugin automatically generates sidebar navigation for guides and content.
|
||||||
- Language prefix is automatically removed from display
|
|
||||||
- URL: `/?page=test&lang=nl` or `/?page=test&lang=en`
|
|
||||||
- Automatic language detection via browser or config
|
|
||||||
|
|
||||||
## 🔧 Requirements
|
### Plugin CSS
|
||||||
|
|
||||||
- **PHP 8.1+**
|
Plugin CSS is automatically loaded after theme CSS, so themes can override plugin styling.
|
||||||
- **Web server** (Apache, Nginx, or PHP built-in server)
|
|
||||||
- **Composer** (for `league/commonmark`)
|
|
||||||
|
|
||||||
## 🛠️ Installation
|
## 📝 Content Examples
|
||||||
|
|
||||||
|
### Markdown with frontmatter
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
plugins: HTMLBlock, Navigation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Page title
|
||||||
|
|
||||||
|
Content in Markdown format...
|
||||||
|
```
|
||||||
|
|
||||||
|
### PHP content
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
$pages = $api->getAllPages();
|
||||||
|
echo "<h1>My Page</h1>";
|
||||||
|
echo "<p>Number of pages: " . count($pages) . "</p>";
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.noorlander.info/E.Noorlander/CodePress.git
|
# Penetration tests
|
||||||
cd CodePress
|
cli/test/pentest/security-test.sh
|
||||||
composer install
|
|
||||||
php -S localhost:8080 -t public
|
# Accessibility tests (WCAG 2.1 AA)
|
||||||
|
cli/test/accessibility.sh
|
||||||
|
|
||||||
|
# Functional tests
|
||||||
|
cli/test/functional/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Documentation
|
## 📞 Support
|
||||||
|
|
||||||
- **[Guide (NL)](guide/nl.codepress.md)**
|
- **Documentation:** [guide/](guide/)
|
||||||
- **[Guide (EN)](guide/en.codepress.md)**
|
- **Issues:** Git repository
|
||||||
- **[TODO](TODO.md)** - Upcoming improvements
|
- **Contact:** commercial@noorlander.info
|
||||||
- **[AGENTS.md](AGENTS.md)** - Developer instructions
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
CodePress CMS is available under a **dual-license model**: AGPL v3 (open-source) or Commercial.
|
**Dual-licensed:**
|
||||||
|
|
||||||
|
- **AGPL v3** - For open-source projects
|
||||||
|
- **Commercial** - For proprietary use
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Built by Edwin Noorlander*
|
**CodePress CMS** - Built by E.Noorlander / CodePress Development Team
|
||||||
@@ -2,216 +2,354 @@
|
|||||||
|
|
||||||
**[🇳🇱 Nederlands](#) | [🇬🇧 English](README.en.md)**
|
**[🇳🇱 Nederlands](#) | [🇬🇧 English](README.en.md)**
|
||||||
|
|
||||||
Een lichtgewicht, file-based content management systeem gebouwd met PHP.
|
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
||||||
|
|
||||||
**Versie:** 1.5.0 | **Licentie:** AGPL v3 / Commercial
|
**Versie:** 2.6.1 | **Licentie:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 📝 **Multi-format Content** - Ondersteunt Markdown, PHP en HTML bestanden
|
- 📝 **Multi-format Content** - Markdown, PHP en HTML bestanden
|
||||||
- 🧭 **Dynamic Navigation** - Automatische menu generatie met dropdowns
|
- 🧭 **Dynamic Navigation** - Automatische menu generatie
|
||||||
- 🌍 **Multi-language** - Nederlands en Engels met automatische detectie
|
- 🌍 **Multi-language** - NL/EN/DE ondersteuning
|
||||||
- 🔍 **Search Functionality** - Volledige tekst zoek door alle content
|
- 🔍 **Search** - Volledige tekst zoekfunctie
|
||||||
- 🧭 **Breadcrumb Navigation** - Intuïtieve navigatiepaden met sidebar toggle
|
- 📱 **Responsive** - Bootstrap 5 thema's
|
||||||
- 🔗 **Auto-linking** - Automatische links tussen pagina's
|
- 🔒 **Security** - 100/100 pentest score
|
||||||
- 📱 **Responsive Design** - Werkt perfect op alle apparaten
|
- 🛡️ **Admin Console** - CodeMirror editor, media beheer, thema's, plugins
|
||||||
- ⚙️ **JSON Configuratie** - Eenvoudige configuratie via JSON
|
- 👥 **Gebruikersrollen** - Admin, Content Beheerder, BI Beheerder, Site Admin
|
||||||
- 🎨 **Thema's** - Aanpasbare thema's met eigen kleuren en achtergronden
|
- 📊 **Analytics** - Bezoekersstatistieken met GeoIP
|
||||||
- 🔒 **Security** - Beveiligde content management (100/100 security score)
|
- 🤖 **BotGuard** - Bot/AI bescherming
|
||||||
- 🛡️ **Admin Console** - Ingebouwd admin paneel met CodeMirror editor, media browser, themabeheer en plugin configuratie
|
- 📈 **Logging** - Uitgebreid logging systeem
|
||||||
|
- 🔌 **Plugin Systeem** - Sidebar plugins met eigen CSS/SCSS, Twig templates
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php -S localhost:8080 -t public
|
# Installeer dependencies
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# Start server met router voor schone URLs (alleen lokaal)
|
||||||
|
php -S localhost:8080 cms/router.php
|
||||||
```
|
```
|
||||||
Bezoek `http://localhost:8080` in je browser.
|
|
||||||
Admin paneel: `http://localhost:8080/admin.php` (login: `admin` / `admin`)
|
**Website:** `http://localhost:8080`
|
||||||
|
**Admin:** `http://localhost:8080/admin` (login: `admin` / `admin`)
|
||||||
|
|
||||||
|
## 📦 Installatie
|
||||||
|
|
||||||
|
### Vereisten
|
||||||
|
|
||||||
|
- **PHP** ≥ 8.0 met extensies: `json`, `mbstring`
|
||||||
|
- **Composer** (PHP dependency manager)
|
||||||
|
- Webserver: **Apache 2.4+** met `mod_rewrite` of **Nginx** met PHP-FPM
|
||||||
|
- Optioneel: `opcache` (aanbevolen voor performance), `git` (voor content versioning), `zip` extensie (voor ZIP backup/restore)
|
||||||
|
|
||||||
|
### Stap 1 — Code en dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url> codepress
|
||||||
|
cd codepress
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stap 2 — Configuratie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.json.example config.json
|
||||||
|
cp admin/config/admin.json.example admin/config/admin.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Pas `config.json` aan met je site titel, taal en plugins. Wijzig het admin wachtwoord in `admin/config/admin.json` (standaard `admin`/`admin`).
|
||||||
|
|
||||||
|
### Stap 3a — Apache 2.4+
|
||||||
|
|
||||||
|
De webroot is de `public/` map. Voorbeeld vhost (`/etc/apache2/sites-available/codepress.conf`):
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName example.com
|
||||||
|
DocumentRoot /var/www/codepress/public
|
||||||
|
|
||||||
|
<Directory /var/www/codepress/public>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/codepress_error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/codepress_access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
Benodigde Apache modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo a2enmod rewrite headers
|
||||||
|
sudo systemctl restart apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
- `mod_rewrite` — voor clean URLs (`/nl/pagina`) en asset-serving
|
||||||
|
- `mod_headers` — voor security headers
|
||||||
|
- `AllowOverride All` — zodat `.htaccess` in `public/` wordt toegepast
|
||||||
|
|
||||||
|
### Stap 3b — Nginx
|
||||||
|
|
||||||
|
Voorbeeld server block (`/etc/nginx/sites-available/codepress`):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name example.com;
|
||||||
|
root /var/www/codepress/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
# Clean URLs: taal-prefixed pagina's
|
||||||
|
location ~ ^/(nl|en|de)(/(.+))?$ {
|
||||||
|
try_files $uri /index.php?lang=$1&page=$2;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin routes
|
||||||
|
location /admin {
|
||||||
|
try_files $uri /admin.php?$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asset-serving via asset.php (themes/plugins/admin buiten webroot)
|
||||||
|
location ~ ^/(themes|plugins)/([^/]+)/assets/(.+)$ {
|
||||||
|
try_files $uri /asset.php;
|
||||||
|
}
|
||||||
|
location ~ ^/admin/assets/(.+)$ {
|
||||||
|
try_files $uri /asset.php;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP via FPM
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Beveiliging: blokkeer toegang tot gevoelige mappen
|
||||||
|
location ~ ^/(content|cms|admin/src|admin/config|admin/storage|var|vendor)/ {
|
||||||
|
deny all;
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.(git|htaccess) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Let op:** Nginx gebruikt geen `.htaccess`. De security headers moeten in de Nginx config worden gezet:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stap 4 — Mappen rechten
|
||||||
|
|
||||||
|
Zorg dat de webserver schrijfrechten heeft op de runtime mappen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chown -R www-data:www-data var/ admin/storage/ content/
|
||||||
|
chmod -R 755 .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stap 5 — Test
|
||||||
|
|
||||||
|
Open de website in je browser. Bij een lege content-map zie je een welkomstpagina. De admin console is bereikbaar via `/admin` (login `admin`/`admin`).
|
||||||
|
|
||||||
|
## 📚 Handleidingen
|
||||||
|
|
||||||
|
Zie **[guide/](guide/)** voor uitgebreide documentatie per rol:
|
||||||
|
|
||||||
|
| Rol | Handleiding |
|
||||||
|
|-----|-------------|
|
||||||
|
| 📝 Redacteur | [Content Beheerder](guide/nl/content-beheerder.md) |
|
||||||
|
| ⚙️ Administrator | [Admin Beheerder](guide/nl/admin-beheerder.md) |
|
||||||
|
| 🎨 Theme bouwer | [Theme Developer](guide/nl/theme-developer.md) |
|
||||||
|
| 💻 Developer | [CodePress Developer](guide/nl/codepress-developer.md) |
|
||||||
|
|
||||||
|
Elke handleiding heeft sub-onderdelen in aparte mappen met een zijbalknavigatie.
|
||||||
|
|
||||||
|
## 👥 Gebruikersrollen
|
||||||
|
|
||||||
|
| Rol | Permissies |
|
||||||
|
|-----|-----------|
|
||||||
|
| **Admin** | Volledige toegang (alles) |
|
||||||
|
| **Content Beheerder** | Content beheer, handleiding |
|
||||||
|
| **BI Beheerder** | Statistieken, logs, handleiding |
|
||||||
|
| **Site Admin** | Thema, plugins, statistieken, logs, update, handleiding |
|
||||||
|
|
||||||
## 📁 Project Structuur
|
## 📁 Project Structuur
|
||||||
|
|
||||||
```
|
```
|
||||||
codepress/
|
codepress/
|
||||||
├── cms/ # Core CMS engine
|
├── cms/ # Core CMS engine
|
||||||
│ ├── core/
|
│ ├── core/class/ # CMS classes (CodePressCMS, ThemeManager, etc.)
|
||||||
│ │ ├── class/
|
│ ├── core/plugin/ # Plugin systeem (PluginManager, CMSAPI)
|
||||||
│ │ │ ├── CodePressCMS.php # Hoofd CMS class
|
│ └── router.php # PHP dev server router (schone URLs)
|
||||||
│ │ │ ├── Logger.php # Logging systeem
|
├── language/ # Taalbestanden (nl/, en/, de/ — elk met site.php + admin.php)
|
||||||
│ │ │ ├── SimpleTemplate.php # Mustache-style template engine
|
├── admin/ # Admin console
|
||||||
│ │ │ ├── Cache.php
|
│ ├── config/ # Admin configuratie (admin.json)
|
||||||
│ │ │ ├── AssetManager.php
|
│ ├── src/AdminAuth.php # Authenticatie, rollen, permissies
|
||||||
│ │ │ ├── SearchEngine.php
|
│ ├── static/ # Statische bestanden (404.html)
|
||||||
│ │ │ ├── ContentSecurityPolicy.php
|
│ ├── storage/ # Logs, cache, geoip
|
||||||
│ │ │ └── ...
|
│ └── theme/default/ # Admin thema
|
||||||
│ │ ├── plugin/
|
│ ├── assets/ # CSS, JS, fonts, codemirror
|
||||||
│ │ │ ├── PluginManager.php # Plugin loader
|
│ ├── views/ # Twig templates (layouts, pages)
|
||||||
│ │ │ └── CMSAPI.php # API voor plugins
|
│ └── theme.json # Admin thema configuratie
|
||||||
│ │ ├── config.php # Config loader
|
├── themes/ # Website thema's
|
||||||
│ │ └── index.php # Bootstrap (autoloader)
|
│ ├── default/ # Standaard thema
|
||||||
│ ├── lang/ # Taalbestanden (nl.php, en.php)
|
│ │ ├── theme.json # Layout mapping, kleuren
|
||||||
│ ├── templates/ # Mustache templates
|
│ │ ├── base.twig # Hoofd layout
|
||||||
│ │ ├── layout.mustache
|
│ │ ├── *.twig # Layout templates
|
||||||
│ │ ├── assets/ (header, navigation, footer)
|
│ │ ├── partials/ # Header, navigation, footer
|
||||||
│ │ ├── markdown_content.mustache
|
│ │ └── assets/ # SCSS, CSS, JS, img
|
||||||
│ │ ├── php_content.mustache
|
├── plugins/ # Plugins
|
||||||
│ │ └── html_content.mustache
|
│ ├── HTMLBlock/ # Voorbeeld sidebar plugin
|
||||||
│ └── router.php # PHP dev server router
|
│ └── Navigation/ # Essentiële navigatie plugin (beschermd)
|
||||||
├── admin/ # Admin paneel
|
│ ├── Navigation.php # Plugin code
|
||||||
│ ├── config/
|
│ ├── plugin.json # Plugin metadata
|
||||||
│ │ ├── app.php # Admin configuratie
|
│ ├── assets/scss/ # Plugin SCSS bron
|
||||||
│ │ └── admin.json # Gebruikers & security
|
│ └── assets/css/ # Plugin CSS
|
||||||
│ ├── src/
|
├── content/ # Website content (.md, .php, .html)
|
||||||
│ │ └── AdminAuth.php # Authenticatie (sessies, bcrypt, CSRF)
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── login.php
|
|
||||||
│ │ ├── layout.php
|
|
||||||
│ │ └── pages/ (dashboard, content, content-edit, content-new,
|
|
||||||
│ │ content-dir-form, content-move-form, config, plugins,
|
|
||||||
│ │ plugins-edit, plugins-new, plugin-config, theme, media, users)
|
|
||||||
│ └── storage/logs/
|
|
||||||
├── cli/test/ # CLI scripts & tests
|
|
||||||
├── plugins/ # CMS plugins (HTMLBlock, MQTTTracker)
|
|
||||||
├── public/ # Web root
|
├── public/ # Web root
|
||||||
│ ├── assets/
|
|
||||||
│ │ ├── css/ (Bootstrap, styles, editor.css)
|
|
||||||
│ │ ├── js/ (Bootstrap, app.js, editor-toolbar.js)
|
|
||||||
│ │ └── codemirror/ (CodeMirror editor + modes)
|
|
||||||
│ ├── index.php # Website entry point
|
│ ├── index.php # Website entry point
|
||||||
│ ├── admin.php # Admin entry point + router
|
│ └── admin.php # Admin entry point + routing
|
||||||
│ └── themes/ # Geuploade thema achtergronden
|
|
||||||
├── themes/ # Thema configuraties
|
|
||||||
│ ├── default/theme.json
|
|
||||||
│ └── test/theme.json
|
|
||||||
├── content/ # Content bestanden
|
|
||||||
├── guide/ # Handleidingen (nl/en)
|
├── guide/ # Handleidingen (nl/en)
|
||||||
├── docs/ # Documentatie
|
│ ├── nl/ # Nederlandse handleidingen
|
||||||
└── config.json # Site configuratie
|
│ └── en/ # Engelse handleidingen
|
||||||
|
├── cli/test/ # Test suites
|
||||||
|
├── var/ # Cache (twig)
|
||||||
|
├── config.json # Site configuratie
|
||||||
|
├── composer.json # PHP dependencies
|
||||||
|
└── version.php # Versie informatie
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚙️ Configuratie
|
## ⚙️ Configuratie
|
||||||
|
|
||||||
### Basis Configuratie (`config.json`)
|
### config.json
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"site_title": "CodePress",
|
"site_title": "CodePress",
|
||||||
"content_dir": "content",
|
|
||||||
"templates_dir": "cms/templates",
|
|
||||||
"default_page": "index",
|
|
||||||
"active_theme": "default",
|
"active_theme": "default",
|
||||||
|
"default_page": "auto",
|
||||||
"language": {
|
"language": {
|
||||||
"default": "nl",
|
"default": "nl",
|
||||||
"available": ["nl", "en"]
|
"available": ["nl", "en"]
|
||||||
},
|
},
|
||||||
"seo": {
|
"enabled_plugins": ["HTMLBlock", "Navigation"],
|
||||||
"description": "CodePress CMS - Lightweight file-based content management system",
|
|
||||||
"keywords": "cms, php, content management, file-based"
|
|
||||||
},
|
|
||||||
"author": {
|
|
||||||
"name": "E. Noorlander",
|
|
||||||
"website": "https://noorlander.info"
|
|
||||||
},
|
|
||||||
"features": {
|
"features": {
|
||||||
"auto_link_pages": true,
|
|
||||||
"search_enabled": true,
|
"search_enabled": true,
|
||||||
"breadcrumbs_enabled": true
|
"breadcrumbs_enabled": true
|
||||||
}
|
},
|
||||||
|
"security": {
|
||||||
|
"block_ai_bots": true,
|
||||||
|
"rate_limit_enabled": true
|
||||||
|
},
|
||||||
|
"analytics": { "enabled": true },
|
||||||
|
"logging": { "enabled": true }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Content Types
|
## 🔧 Dependencies
|
||||||
|
|
||||||
### Markdown (.md)
|
- **PHP ≥8.0** met extensies: json, mbstring
|
||||||
- Auto-linking tussen pagina's
|
- **Composer** packages:
|
||||||
- GitHub Flavored Markdown via `league/commonmark`
|
- twig/twig (templating)
|
||||||
- Automatische titel extractie
|
- scssphp/scssphp (SCSS compilatie)
|
||||||
- Multi-language met `nl.bestand.md` en `en.bestand.md`
|
- league/commonmark (Markdown met HeadingPermalinks)
|
||||||
|
- maxmind-db/reader (GeoIP)
|
||||||
|
|
||||||
### PHP (.php)
|
## 🔐 Security
|
||||||
- Volledige PHP ondersteuning
|
|
||||||
- Dynamische content generatie
|
|
||||||
|
|
||||||
### HTML (.html)
|
- ✅ XSS preventie (htmlspecialchars)
|
||||||
- Statische HTML pagina's
|
- ✅ CSRF tokens (admin formulieren)
|
||||||
- Bootstrap componenten
|
- ✅ Path traversal preventie (realpath checks)
|
||||||
|
- ✅ Secure cookies (HttpOnly, SameSite)
|
||||||
|
- ✅ Security headers (X-Frame-Options, CSP)
|
||||||
|
- ✅ Bot/AI bescherming (BotGuard)
|
||||||
|
- ✅ Rate limiting per IP
|
||||||
|
- ✅ Role-based access control (RBAC)
|
||||||
|
|
||||||
## 🛡️ Admin Console
|
## 🔌 Plugins
|
||||||
|
|
||||||
CodePress bevat een ingebouwd admin paneel voor het beheren van je website.
|
### Plugin structuur
|
||||||
|
|
||||||
**Toegang:** `/admin.php` | **Standaard login:** `admin` / `admin`
|
```
|
||||||
|
plugins/MijnPlugin/
|
||||||
### Modules
|
├── MijnPlugin.php # Plugin code (naam = pluginnaam)
|
||||||
- **Dashboard** - Overzicht met statistieken en snelle acties
|
├── plugin.json # Plugin metadata
|
||||||
- **Content** - Bestanden browsen, aanmaken, bewerken, hernoemen en verwijderen
|
├── assets/scss/ # Plugin SCSS bron
|
||||||
- **CodeMirror Editor** - Syntax highlighting met toolbar (vet, cursief, kop, link, afbeelding, lijst, media)
|
└── assets/css/ # Plugin CSS (na compilatie)
|
||||||
- **Media Browser** - Uploaden en invoegen van afbeeldingen/video/audio met size prompt
|
|
||||||
- **Configuratie** - `config.json` bewerken met JSON-validatie
|
|
||||||
- **Thema's** - Thema aanmaken, activeren, bewerken, verwijderen met achtergrond upload
|
|
||||||
- **Plugins** - Overzicht, installeren, configureren en toggle
|
|
||||||
- **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 via `realpath()` + prefix-check
|
|
||||||
- Session timeout (30 min)
|
|
||||||
- Security headers: CSP, X-Frame-Options, X-Content-Type-Options
|
|
||||||
|
|
||||||
> **Belangrijk:** Wijzig het standaard wachtwoord direct na installatie via Gebruikers.
|
|
||||||
|
|
||||||
## 🎨 Thema's
|
|
||||||
|
|
||||||
Thema's worden opgeslagen in `themes/naam/theme.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "default",
|
|
||||||
"label": "Standard",
|
|
||||||
"header_color": "#0a369d",
|
|
||||||
"header_font_color": "#ffffff",
|
|
||||||
"navigation_color": "#2754b4",
|
|
||||||
"navigation_font_color": "#ffffff",
|
|
||||||
"sidebar_background": "#f8f9fa",
|
|
||||||
"sidebar_border": "#dee2e6",
|
|
||||||
"background_image": "/themes/default_bg.jpg"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌍 Multi-language Support
|
### Essentiële plugins
|
||||||
|
|
||||||
- Bestandsnaam conventie: `nl.[pagina].md` en `en.[page].md`
|
De **Navigation** plugin is een essentiële plugin en kan niet worden gedeactiveerd, bewerkt of verwijderd. Deze plugin genereert automatisch de zijbalknavigatie voor handleidingen en content.
|
||||||
- Taalprefix wordt automatisch verwijderd uit weergave
|
|
||||||
- URL: `/?page=test&lang=nl` of `/?page=test&lang=en`
|
|
||||||
- Automatische taal detector op basis van browser of config
|
|
||||||
|
|
||||||
## 🔧 Vereisten
|
### Plugin CSS
|
||||||
|
|
||||||
- **PHP 8.1+**
|
Plugin CSS wordt automatisch geladen na thema CSS, zodat thema's plugin styling kunnen overschrijven.
|
||||||
- **Webserver** (Apache, Nginx, of PHP built-in server)
|
|
||||||
- **Composer** (voor `league/commonmark`)
|
|
||||||
|
|
||||||
## 🛠️ Installatie
|
## 📝 Content Voorbeelden
|
||||||
|
|
||||||
|
### Markdown met frontmatter
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
plugins: HTMLBlock, Navigation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pagina titel
|
||||||
|
|
||||||
|
Content in Markdown formaat...
|
||||||
|
```
|
||||||
|
|
||||||
|
### PHP content
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
$pages = $api->getAllPages();
|
||||||
|
echo "<h1>Mijn Pagina</h1>";
|
||||||
|
echo "<p>Aantal pagina's: " . count($pages) . "</p>";
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.noorlander.info/E.Noorlander/CodePress.git
|
# Penetration tests
|
||||||
cd CodePress
|
cli/test/pentest/security-test.sh
|
||||||
composer install
|
|
||||||
php -S localhost:8080 -t public
|
# Accessibility tests (WCAG 2.1 AA)
|
||||||
|
cli/test/accessibility.sh
|
||||||
|
|
||||||
|
# Functionele tests
|
||||||
|
cli/test/functional/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Documentatie
|
## 📞 Ondersteuning
|
||||||
|
|
||||||
- **[Handleiding (NL)](guide/nl.codepress.md)**
|
- **Documentatie:** [guide/](guide/)
|
||||||
- **[Guide (EN)](guide/en.codepress.md)**
|
- **Issues:** Git repository
|
||||||
- **[TODO](TODO.md)** - Openstaande verbeteringen
|
- **Contact:** commercial@noorlander.info
|
||||||
- **[AGENTS.md](AGENTS.md)** - Ontwikkelaar instructies
|
|
||||||
|
|
||||||
## 📄 Licentie
|
## 📄 Licentie
|
||||||
|
|
||||||
CodePress CMS is beschikbaar onder een **dual-license model**: AGPL v3 (open-source) of Commercial.
|
**Dual-licensed:**
|
||||||
|
|
||||||
|
- **AGPL v3** - Voor open-source projecten
|
||||||
|
- **Commercial** - Voor propriëtair gebruik
|
||||||
|
|
||||||
|
Zie [LICENSE](LICENSE) voor details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Gebouwd door Edwin Noorlander*
|
**CodePress CMS** - Gebouwd door E.Noorlander / CodePress Development Team
|
||||||
@@ -1,119 +1,65 @@
|
|||||||
# CodePress TODO
|
# TODO
|
||||||
|
|
||||||
## ✅ Voltooid (recent)
|
## Voor elke versie verhoging. Deze nooit weghalen.
|
||||||
|
- [ ] Pentest controles uitgevoerd
|
||||||
|
- [ ] WCAG 2.1 AA accessibility tests
|
||||||
|
- [ ] Volledige git commit maken.
|
||||||
|
- [ ] Verslag maken voor opdrcht gever
|
||||||
|
- [ ] Na convormatie versie verhogen
|
||||||
|
- [ ] Bij Voltooid versie ophoging aanmaken en deze allemaal unvinken voor volgende ronde.
|
||||||
|
|
||||||
### Opschoning & kleine verbeteringen (v1.9.1)
|
## Te doen ⏳
|
||||||
- [x] Wereldkaart: nul-opgevulde ISO-nummers werden niet herkend, waardoor 31 landen ontbraken (o.a. Brazilië, Australië, België, Oostenrijk, Algerije)
|
|
||||||
- [x] Wereldkaart: Rusland en Fiji smeerden over de datumgrens uit over de volle 360°; ringen worden nu ontvouwen en aan beide randen getekend
|
|
||||||
- [x] Wereldkaart: bijgesneden op 84°N–60°Z, `fill-rule="evenodd"` voor enclaves, 174 landen
|
|
||||||
- [x] `Logger::tail()` leest het bestand nu achterwaarts in blokken i.p.v. volledig in het geheugen
|
|
||||||
- [x] Externe links krijgen `rel="noopener noreferrer"` — in de footer en automatisch in Markdown-content via `ExternalLinkExtension`
|
|
||||||
- [x] `formatDisplayName()` opgeschoond en beveiligd tegen lege invoer (PHP-waarschuwing verholpen)
|
|
||||||
- [x] Statistieken exporteren als CSV (met BOM voor Excel) of JSON
|
|
||||||
- [x] GeoIP-database werkt zichzelf automatisch bij als hij ouder is dan 35 dagen
|
|
||||||
- [x] Sneltoetsen in de editor: Ctrl/Cmd+S opslaan, Ctrl/Cmd+N nieuwe pagina
|
|
||||||
- [x] Zoekfilter in de admin content browser (live, met teller en Escape om te wissen)
|
|
||||||
- [x] Content versioning: bij elke save een tijdgestempelde `.bak` in `content/-backups/`, laatste 5 versies per bestand
|
|
||||||
|
|
||||||
### Statistieken & GeoIP (v1.9.0)
|
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
|
||||||
- [x] `GeoIP` class met providerketen: lokaal (DB-IP Lite) → MaxMind `.mmdb` → externe API, met automatische fallback
|
- [ ] Fase 1: Config-resolutie
|
||||||
- [x] Eigen pure-PHP MMDB-lezer (`MMDBReader`), geen Composer-afhankelijkheid nodig
|
- [ ] For apache instants domein settings /public/noorlander.info/ or /public/mycode.name/
|
||||||
- [x] `cli/geoip-update.php` — downloadt DB-IP Lite en bouwt compacte binaire index (354k IPv4 + 342k IPv6 records)
|
- [ ] config.domains.json (registry: host, aliases, redirect, config) + .example
|
||||||
- [x] Binary search lookup via `fseek` voor IPv4 (10 bytes/record) en IPv6 (34 bytes/record)
|
- [ ] cms/core/domain.php: normalizeHost/getDomainRegistry/resolveDomain/loadSiteConfigForHost
|
||||||
- [x] Placeholder-landcodes (`ZZ`/`XX`) tellen als onbekend
|
- [ ] cms/core/config.php refactor → herbruikbare functie, compatibel blijven
|
||||||
- [x] `cli/generate-world-map.php` — genereert SVG-wereldkaart uit Natural Earth TopoJSON (publiek domein) met ISO alpha-2 id's
|
- [ ] Per-domein config-bestanden (content_dir, active_theme) in the main content dir. like: /content/domain1 /content/domain2 os /content/noorlander.info/ /content/mycode.name/
|
||||||
- [x] `Analytics` class met aggregatie in `admin/storage/stats.json` (LOCK_EX), overleeft het wissen van logs
|
- [ ] Fase 2: Front-end
|
||||||
- [x] Admin pagina `/admin/statistics`: KPI's, choropleth wereldkaart met tooltips, landenlijst met vlaggen, top pagina's, dagelijkse grafiek, referrers
|
- [ ] cms/router.php: taal-prefix dynamisch uit actieve config
|
||||||
- [x] Periodefilter 7 / 30 / 90 dagen / alles
|
- [ ] public/.htaccess: generieke `([a-z]{2})` taalregel
|
||||||
- [x] GeoIP- en privacy-instellingen in admin (provider, `.mmdb` pad, API URL/sleutel, bewaartermijn)
|
- [ ] CodePressCMS::getCurrentLanguage(): validatie via config['language']['available']
|
||||||
- [x] Knop "GeoIP database bijwerken" en "Statistieken wissen" in admin
|
- [ ] getInternalHosts(): registry-hosts toevoegen (cross-domein links = intern)
|
||||||
- [x] IP-anonimisering als schakelaar (`RequestLogger::anonymizeIp()`), standaard uit
|
- [ ] public/index.php: /-media/ en /-assets/ via actieve content_dir
|
||||||
- [x] Landkolom met vlag in requests log + KPI-kaarten op dashboard
|
- [ ] Fase 3: Admin (domeinbeheer + switch)
|
||||||
- [x] `requests.log` uitgebreid met landcode (9e veld), parser accepteert 7/8/9 velden
|
- [ ] admin/config/app.php: domains_json pad
|
||||||
|
- [ ] public/admin.php: $_SESSION['admin_domain'] + routes domains/domain-switch
|
||||||
|
- [ ] domains.twig + sidebar-item/badge in admin.twig
|
||||||
|
- [ ] Bestaande handlers laten werken op actief domein (config_json/content_dir patchen)
|
||||||
|
- [ ] Fase 4: Housekeeping
|
||||||
|
- [ ] .gitignore: config.*.json, content-*/
|
||||||
|
- [ ] AGENTS.md + config.json.example bijwerken
|
||||||
|
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
||||||
|
|
||||||
### Beveiliging (v1.8.0)
|
## Voltooid ✅
|
||||||
- [x] `BotGuard` engine: AI-crawlers, zoekmachines, scrapers en lege user-agents herkennen en blokkeren
|
|
||||||
- [x] Admin pagina `/admin/security` met schakelaars, rate limiting en IP block/allowlist
|
|
||||||
- [x] Rate limiting per IP (HTTP 429 met `Retry-After`)
|
|
||||||
- [x] Dynamische `/robots.txt` en `noai`/`noimageai` meta-tags
|
|
||||||
- [x] Statuskolom met badges in requests log
|
|
||||||
- [x] Echte bezoeker-IP achter HAProxy/PFSense (`RequestLogger::getClientIp()`, 2-pass publiek IP filter)
|
|
||||||
- [x] HAProxy/PFSense handleiding (`docs/haproxy-bot-blocking.md`)
|
|
||||||
- [x] Eén-klik systeemupdate via `/admin/update` met controle op Git-schrijfrechten
|
|
||||||
- [x] `config.json` en `admin/config/admin.json` uit Git, automatisch aangemaakt indien afwezig
|
|
||||||
- [x] **ARIAComponents.php parse error** — opgelost op regels 67, 137 en 262 (`'UTF-8)` → `'UTF-8')`)
|
|
||||||
|
|
||||||
### Media & Editor
|
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
|
||||||
- [x] Media browser modal met upload, thumbnail grid, en size-prompt
|
- [x] version.php changelog verwijderen (staat in git)
|
||||||
- [x] Media knop in editor toolbar voor alle modes (md/html/php)
|
- [x] Guide mappenstructuur reorganiseren (NL/EN → rollen)
|
||||||
- [x] Recursieve scan van `content/` voor media bestanden (ipv alleen `-assets/`)
|
- [x] README.md compacter maken met verwijzingen naar guide
|
||||||
- [x] `/-media/` URL prefix voor media bestanden (consistente routing, geen special cases)
|
- [x] Admin dashboard template check — Twig-commentaren `{# ... #}` verwijderd uit Dashboard.php
|
||||||
- [x] `/-assets/` blijft werken voor backward compatibility
|
- [x] Plugins — zichtbaar verschil tussen system en content plugins (badge + border + icoon in plugins.twig)
|
||||||
- [x] Size prompt voor afbeeldingen: Markdown ``, HTML/PHP `<img>` met width/height
|
- [x] Plugins — alleen actieve plugins zichtbaar in sidebar (PluginManager laadt alleen enabled plugins)
|
||||||
- [x] Editor change detectie: `editor.on('change', ...)` werkt nu correct
|
- [x] Plugins — dubbele enabled_plugins config opgelost (plugins.enabled verwijderd, alleen enabled_plugins op top-level)
|
||||||
- [x] "Terug" knop verandert naar rode "Annuleren" bij ongewijzigde wijzigingen (content-edit + content-new)
|
- [x] Admin config — Analytics & Logging toggles verwijderd van config-pagina
|
||||||
- [x] Upload knop disabled tot bestand geselecteerd
|
- [x] Handleidingen gecontroleerd en bijgewerkt (20 bestanden NL+EN: configuratie, plugins, plugin-development, core-classes, theme-json, layouts, scss-styling, admin-beheerder, nieuw-thema, architectuur)
|
||||||
- [x] "Aanmaken" knop disabled tot bestandsnaam ingevuld
|
- [x] Content backup/restore optie + content-git repository integratie (ContentBackup class, content-backup.twig, ZIP backup/restore, git init/commit/log/restore)
|
||||||
- [x] Editor mode switching in content-new werkt via `switchMode()` (mode + toolbar + data-ext)
|
- [x] Pentest controles uitgevoerd (30/30 tests geslaagd — 0 vulnerabilities)
|
||||||
|
- [x] WCAG 2.1 AA accessibility tests (25/25 tests geslaagd — 100% compliance, test-script verbeterd met min/max checks en grep -E)
|
||||||
|
|
||||||
### Content Management
|
## v2.6.1 (2026-08-17) ✅
|
||||||
- [x] Inline rename veld in content-edit pagina (geen aparte rename knop)
|
- [x] Pentest controles uitgevoerd (30/30)
|
||||||
- [x] Bestanden en mappen verplaatsen (content-move)
|
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
||||||
- [x] Breadcrumb toont geen `.` meer (dirname check op PHP niveau)
|
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt
|
||||||
- [x] Verwijderde aparte `content-file-rename` route/handler/template
|
- [x] Installatie instructies toegevoegd aan README (Apache2/Nginx/PHP/composer)
|
||||||
|
- [x] Verwijderde vendor packages: php-mqtt/client, mustache/mustache (uit composer + autoloader)
|
||||||
|
- [x] Verslag gemaakt (docs/release-notes/v2.6.1.md)
|
||||||
|
- [x] Versie verhoogd naar 2.6.1
|
||||||
|
|
||||||
### Thema's
|
## v2.6.0 (2026-08-15) ✅
|
||||||
- [x] Thema's in subdirectory `themes/naam/theme.json` (ipv `themes/naam.json`)
|
- [x] Pentest controles uitgevoerd (30/30)
|
||||||
- [x] Thema CRUD in admin (aanmaken, activeren, bewerken, verwijderen)
|
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
||||||
- [x] File upload voor thema achtergrond afbeeldingen
|
- [x] Verslag gemaakt (docs/release-notes/v2.6.0.md)
|
||||||
|
- [x] Versie verhoogd naar 2.6.0
|
||||||
### Security & Code Quality (docs/TODO.md)
|
|
||||||
- [x] Path traversal fix (`realpath()` + prefix-check)
|
|
||||||
- [x] JWT secret fallback verwijderd
|
|
||||||
- [x] `executePhpFile()` pad-restrictie
|
|
||||||
- [x] IP spoofing fix in MQTTTracker
|
|
||||||
- [x] Debug uitgezet in admin config
|
|
||||||
- [x] Cookie security (Secure/HttpOnly/SameSite)
|
|
||||||
- [x] Dead code verwijderd
|
|
||||||
- [x] `htmlspecialchars()` op bestandspad gecorrigeerd
|
|
||||||
- [x] Ongebruikte methode `scanForPageNames()` verwijderd
|
|
||||||
- [x] Breadcrumb titels geescaped
|
|
||||||
- [x] Taalparameter in zoekresultaat-URLs
|
|
||||||
- [x] Operator precedence bug in MQTTTracker
|
|
||||||
- [x] Hardcoded strings vervangen
|
|
||||||
- [x] HTML lang attribuut dynamisch gemaakt
|
|
||||||
- [x] console.log verwijderd
|
|
||||||
- [x] Sidebar toggle aria attributes
|
|
||||||
|
|
||||||
## 🔴 Nog openstaand
|
|
||||||
|
|
||||||
### Kritiek
|
|
||||||
- [ ] **Plugin auto-loading** — Elke map in `plugins/` wordt blind geladen zonder allowlist of validatie (`PluginManager.php:40` in docs/TODO.md)
|
|
||||||
|
|
||||||
### Hoog
|
|
||||||
- [ ] **autoLinkPageTitles()** — Regex kan geneste `<a>` tags produceren (`CodePressCMS.php`)
|
|
||||||
- [ ] **MQTT wachtwoord** — Credentials in plain text JSON (`MQTTTracker.php`)
|
|
||||||
- [ ] **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 API** — Uitgebreide API voor plugins zodat ze kunnen inhaken op CMS events (hooks/filters): `onPageLoad`, `onBeforeRender`, `onAfterRender`, `onSearch`, `onMenuBuild`
|
|
||||||
|
|
||||||
### Medium
|
|
||||||
- [ ] **ctime is geen creatietijd op Linux** — `stat()` ctime is inode-wijzigingstijd. Deels ondervangen: frontmatter `created` heeft voorrang en de footer verbergt de datum als beide gelijk zijn (`CodePressCMS.php`)
|
|
||||||
- [ ] **Wachtwoord wijzigen eigen account** — Apart formulier voor ingelogde gebruiker om eigen wachtwoord te wijzigen (met huidig wachtwoord verificatie)
|
|
||||||
- [ ] **Bestand uploaden** — Uploaden naar andere mappen dan `-assets/` via admin Content pagina
|
|
||||||
- [ ] **Content preview** — Live preview van Markdown/HTML content naast de editor
|
|
||||||
- [ ] **Backups terugzetten** — Versies uit `content/-backups/` bekijken en herstellen vanuit de admin
|
|
||||||
|
|
||||||
### Laag
|
|
||||||
- [ ] **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
|
|
||||||
- [ ] **mobile.css override Bootstrap utilities** met `!important`
|
|
||||||
- [ ] **Drag & drop** — Bestanden herordenen/verplaatsen via drag & drop
|
|
||||||
- [ ] **Dark mode** — Admin panel dark mode toggle
|
|
||||||
- [ ] **Responsive admin** — Admin sidebar inklapbaar op mobiel (nu is het gestacked)
|
|
||||||
- [ ] **stats.json bij hoog verkeer** — Nu één schrijfactie met LOCK_EX per request. Bij veel verkeer eventueel opsplitsen naar dagbestanden of APCu
|
|
||||||
- [ ] **Steden op de kaart** — Nu alleen landniveau; met een City-database ook stippen per stad plotten
|
|
||||||
- [ ] **Kleine landen op de kaart** — Natural Earth 110m bevat geen Monaco, Vaticaanstad, Liechtenstein en Singapore; eventueel 50m-dataset gebruiken
|
|
||||||
|
|||||||
+129
-9
@@ -9,6 +9,28 @@ class AdminAuth
|
|||||||
private array $adminConfig;
|
private array $adminConfig;
|
||||||
private string $lockFile;
|
private string $lockFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role definitions with permissions.
|
||||||
|
* Each role maps to a list of allowed route prefixes.
|
||||||
|
* 'admin' has wildcard '*' access.
|
||||||
|
*/
|
||||||
|
public const ROLE_PERMISSIONS = [
|
||||||
|
'admin' => ['*'],
|
||||||
|
'content-manager' => ['dashboard', 'content', 'content-edit', 'content-new', 'content-delete', 'content-dir-create', 'content-dir-rename', 'content-dir-delete', 'content-move', 'content-backup', 'content-restore', 'content-git-init', 'content-git-commit', 'content-git-restore', 'guide', 'logout'],
|
||||||
|
'bi-manager' => ['dashboard', 'statistics', 'logs', 'guide', 'logout'],
|
||||||
|
'site-admin' => ['dashboard', 'theme', 'theme-new', 'plugins', 'plugins-new', 'plugins-edit', 'plugins-config', 'plugins-toggle', 'plugins-delete', 'statistics', 'logs', 'update', 'guide', 'logout'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable role labels.
|
||||||
|
*/
|
||||||
|
public const ROLE_LABELS = [
|
||||||
|
'admin' => 'Admin',
|
||||||
|
'content-manager' => 'Content Beheerder',
|
||||||
|
'bi-manager' => 'BI Beheerder',
|
||||||
|
'site-admin' => 'Site Admin',
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(array $appConfig)
|
public function __construct(array $appConfig)
|
||||||
{
|
{
|
||||||
$this->config = $appConfig;
|
$this->config = $appConfig;
|
||||||
@@ -148,10 +170,58 @@ class AdminAuth
|
|||||||
if (!$this->isAuthenticated()) {
|
if (!$this->isAuthenticated()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return [
|
$username = $_SESSION['admin_user'];
|
||||||
'username' => $_SESSION['admin_user'],
|
$userData = [
|
||||||
|
'username' => $username,
|
||||||
'role' => $_SESSION['admin_role'] ?? 'admin'
|
'role' => $_SESSION['admin_role'] ?? 'admin'
|
||||||
];
|
];
|
||||||
|
// Enrich with profile fields from admin.json
|
||||||
|
$userEntry = $this->findUser($username);
|
||||||
|
if ($userEntry) {
|
||||||
|
$userData['email'] = $userEntry['email'] ?? '';
|
||||||
|
$userData['author_name'] = $userEntry['author_name'] ?? '';
|
||||||
|
$userData['author_email'] = $userEntry['author_email'] ?? '';
|
||||||
|
}
|
||||||
|
return $userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the role of the current user.
|
||||||
|
*/
|
||||||
|
public function getCurrentRole(): string
|
||||||
|
{
|
||||||
|
return $_SESSION['admin_role'] ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user has permission to access a route.
|
||||||
|
*/
|
||||||
|
public function hasPermission(string $route): bool
|
||||||
|
{
|
||||||
|
$role = $this->getCurrentRole();
|
||||||
|
$permissions = self::ROLE_PERMISSIONS[$role] ?? ['dashboard', 'logout'];
|
||||||
|
|
||||||
|
if (in_array('*', $permissions, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($route, $permissions, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available roles.
|
||||||
|
*/
|
||||||
|
public static function getRoles(): array
|
||||||
|
{
|
||||||
|
return self::ROLE_LABELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get role label.
|
||||||
|
*/
|
||||||
|
public static function getRoleLabel(string $role): string
|
||||||
|
{
|
||||||
|
return self::ROLE_LABELS[$role] ?? $role;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCsrfToken(): string
|
public function getCsrfToken(): string
|
||||||
@@ -176,16 +246,23 @@ class AdminAuth
|
|||||||
|
|
||||||
public function getUsers(): array
|
public function getUsers(): array
|
||||||
{
|
{
|
||||||
return array_map(function ($u) {
|
$users = [];
|
||||||
return [
|
foreach ($this->adminConfig['users'] ?? [] as $u) {
|
||||||
|
$role = $u['role'] ?? 'admin';
|
||||||
|
$users[$u['username']] = [
|
||||||
'username' => $u['username'],
|
'username' => $u['username'],
|
||||||
'role' => $u['role'] ?? 'admin',
|
'role' => $role,
|
||||||
'created' => $u['created'] ?? ''
|
'role_label' => self::getRoleLabel($role),
|
||||||
|
'created' => $u['created'] ?? '',
|
||||||
|
'email' => $u['email'] ?? '',
|
||||||
|
'author_name' => $u['author_name'] ?? '',
|
||||||
|
'author_email' => $u['author_email'] ?? '',
|
||||||
];
|
];
|
||||||
}, $this->adminConfig['users'] ?? []);
|
}
|
||||||
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addUser(string $username, string $password, string $role = 'admin'): array
|
public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||||
{
|
{
|
||||||
if ($this->findUser($username)) {
|
if ($this->findUser($username)) {
|
||||||
return ['success' => false, 'message' => 'Gebruiker bestaat al.'];
|
return ['success' => false, 'message' => 'Gebruiker bestaat al.'];
|
||||||
@@ -193,18 +270,61 @@ class AdminAuth
|
|||||||
if (strlen($password) < 8) {
|
if (strlen($password) < 8) {
|
||||||
return ['success' => false, 'message' => 'Wachtwoord moet minimaal 8 tekens zijn.'];
|
return ['success' => false, 'message' => 'Wachtwoord moet minimaal 8 tekens zijn.'];
|
||||||
}
|
}
|
||||||
|
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
||||||
|
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
||||||
|
}
|
||||||
|
|
||||||
$this->adminConfig['users'][] = [
|
$this->adminConfig['users'][] = [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
|
'email' => $email,
|
||||||
|
'author_name' => $authorName,
|
||||||
|
'author_email' => $authorEmail,
|
||||||
'created' => date('Y-m-d')
|
'created' => date('Y-m-d')
|
||||||
];
|
];
|
||||||
$this->saveAdminConfig();
|
$this->saveAdminConfig();
|
||||||
$this->log('info', "Gebruiker aangemaakt: {$username}");
|
$this->log('info', "Gebruiker aangemaakt: {$username} (rol: {$role})");
|
||||||
return ['success' => true, 'message' => 'Gebruiker aangemaakt.'];
|
return ['success' => true, 'message' => 'Gebruiker aangemaakt.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the profile (email, author_name, author_email) of a user.
|
||||||
|
*/
|
||||||
|
public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||||
|
{
|
||||||
|
foreach ($this->adminConfig['users'] as &$userEntry) {
|
||||||
|
if ($userEntry['username'] === $username) {
|
||||||
|
$userEntry['email'] = $email;
|
||||||
|
$userEntry['author_name'] = $authorName;
|
||||||
|
$userEntry['author_email'] = $authorEmail;
|
||||||
|
$this->saveAdminConfig();
|
||||||
|
$this->log('info', "Profiel bijgewerkt: {$username}");
|
||||||
|
return ['success' => true, 'message' => 'Profiel opgeslagen.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the role of an existing user.
|
||||||
|
*/
|
||||||
|
public function changeRole(string $username, string $role): array
|
||||||
|
{
|
||||||
|
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
||||||
|
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
||||||
|
}
|
||||||
|
foreach ($this->adminConfig['users'] as &$user) {
|
||||||
|
if ($user['username'] === $username) {
|
||||||
|
$user['role'] = $role;
|
||||||
|
$this->saveAdminConfig();
|
||||||
|
$this->log('info', "Rol gewijzigd: {$username} -> {$role}");
|
||||||
|
return ['success' => true, 'message' => 'Rol gewijzigd.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||||
|
}
|
||||||
|
|
||||||
public function deleteUser(string $username): array
|
public function deleteUser(string $username): array
|
||||||
{
|
{
|
||||||
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="error-page text-center py-5">
|
||||||
|
<div class="error-code display-1 fw-bold text-primary mb-3">404</div>
|
||||||
|
<h1 class="h2 mb-3">Pagina niet gevonden</h1>
|
||||||
|
<p class="text-muted mb-4">De pagina die u zoekt bestaat niet of is verplaatst.</p>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="bi bi-house me-1" aria-hidden="true"></i>
|
||||||
|
Terug naar de hoofdpagina
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
<?php
|
|
||||||
// Load theme sidebar color from site config
|
|
||||||
$layoutConfigFile = __DIR__ . '/../../config.json';
|
|
||||||
$layoutSiteConfig = file_exists($layoutConfigFile) ? json_decode(file_get_contents($layoutConfigFile), true) : [];
|
|
||||||
$layoutActiveTheme = $layoutSiteConfig['active_theme'] ?? 'default';
|
|
||||||
$layoutThemeDir = dirname(__DIR__, 1) . '/../themes/' . $layoutActiveTheme;
|
|
||||||
$layoutThemeFile = $layoutThemeDir . '/theme.json';
|
|
||||||
$layoutThemeConfig = file_exists($layoutThemeFile) ? json_decode(file_get_contents($layoutThemeFile), true) : [];
|
|
||||||
$layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
|
|
||||||
?><!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: <?= htmlspecialchars($layoutSidebarColor) ?>; 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>
|
|
||||||
<?php if (in_array($route ?? '', ['content-edit', 'content-new', 'plugins-edit'])): ?>
|
|
||||||
<link rel="stylesheet" href="/assets/codemirror/codemirror.min.css">
|
|
||||||
<link rel="stylesheet" href="/assets/css/editor.css">
|
|
||||||
<?php endif; ?>
|
|
||||||
</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/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/content">
|
|
||||||
<i class="bi bi-file-earmark-text"></i> Content
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'theme' ? 'active' : '' ?>" href="/admin/theme">
|
|
||||||
<i class="bi bi-palette"></i> Thema
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'config' ? 'active' : '' ?>" href="/admin/config">
|
|
||||||
<i class="bi bi-sliders"></i> Configuratie
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'security' ? 'active' : '' ?>" href="/admin/security">
|
|
||||||
<i class="bi bi-shield-check"></i> Beveiliging
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'statistics' ? 'active' : '' ?>" href="/admin/statistics">
|
|
||||||
<i class="bi bi-bar-chart"></i> Statistieken
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'plugins' ? 'active' : '' ?>" href="/admin/plugins">
|
|
||||||
<i class="bi bi-plug"></i> Plugins
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'users' ? 'active' : '' ?>" href="/admin/users">
|
|
||||||
<i class="bi bi-people"></i> Gebruikers
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'guide' ? 'active' : '' ?>" href="/admin/guide">
|
|
||||||
<i class="bi bi-book"></i> Handleiding
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'logs' ? 'active' : '' ?>" href="/admin/logs">
|
|
||||||
<i class="bi bi-journal-text"></i> Logs
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link <?= ($route ?? '') === 'update' ? 'active' : '' ?>" href="/admin/update">
|
|
||||||
<i class="bi bi-cloud-arrow-down"></i> Update
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item mt-3">
|
|
||||||
<a class="nav-link" href="/" 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/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 'security':
|
|
||||||
require __DIR__ . '/pages/security.php';
|
|
||||||
break;
|
|
||||||
case 'statistics':
|
|
||||||
require __DIR__ . '/pages/statistics.php';
|
|
||||||
break;
|
|
||||||
# Media route (removed from menu)
|
|
||||||
// Uncomment if media functionality is needed elsewhere
|
|
||||||
// require __DIR__ . '/pages/media.php';
|
|
||||||
break;
|
|
||||||
case 'theme':
|
|
||||||
require __DIR__ . '/pages/theme.php';
|
|
||||||
break;
|
|
||||||
case 'plugins':
|
|
||||||
require __DIR__ . '/pages/plugins.php';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'plugins-new':
|
|
||||||
require __DIR__ . '/pages/plugins-new.php';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'plugins-edit':
|
|
||||||
require __DIR__ . '/pages/plugins-edit.php';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'plugins-config':
|
|
||||||
require __DIR__ . '/pages/plugin-config.php';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'content-dir-rename':
|
|
||||||
require __DIR__ . '/pages/content-dir-form.php';
|
|
||||||
break;
|
|
||||||
case 'content-move':
|
|
||||||
require __DIR__ . '/pages/content-move-form.php';
|
|
||||||
break;
|
|
||||||
case 'users':
|
|
||||||
require __DIR__ . '/pages/users.php';
|
|
||||||
break;
|
|
||||||
case 'guide':
|
|
||||||
require __DIR__ . '/pages/guide.php';
|
|
||||||
break;
|
|
||||||
case 'logs':
|
|
||||||
require __DIR__ . '/pages/logs.php';
|
|
||||||
break;
|
|
||||||
case 'update':
|
|
||||||
require __DIR__ . '/pages/update.php';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<?php if (in_array($route ?? '', ['content-edit', 'content-new', 'plugins-edit'])): ?>
|
|
||||||
<script src="/assets/codemirror/codemirror.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/mode/markdown.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/mode/xml.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/mode/htmlmixed.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/mode/php.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/mode/clike.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/mode/css.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/mode/javascript.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/addon/edit/closebrackets.min.js"></script>
|
|
||||||
<script src="/assets/codemirror/addon/selection/active-line.min.js"></script>
|
|
||||||
<script src="/assets/js/editor-toolbar.js"></script>
|
|
||||||
<?php endif; ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-sliders"></i> Configuratie</h2>
|
|
||||||
|
|
||||||
<form method="POST" action="/admin/config">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-globe"></i> Algemene instellingen
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="site_title" class="form-label">Site titel</label>
|
|
||||||
<input type="text" class="form-control" id="site_title" name="site_title"
|
|
||||||
value="<?= htmlspecialchars($configData['site_title'] ?? 'CodePress') ?>">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="default_page" class="form-label">Standaard/startpagina</label>
|
|
||||||
<select name="default_page" id="default_page" class="form-select">
|
|
||||||
<option value="auto" <?= ($configData['default_page'] ?? 'auto') === 'auto' ? 'selected' : '' ?>>Auto (eerste beschikbare pagina)</option>
|
|
||||||
<option value="newest" <?= ($configData['default_page'] ?? '') === 'newest' ? 'selected' : '' ?>>Nieuwste (laatste gewijzigde pagina)</option>
|
|
||||||
<?php foreach ($availablePages as $page): ?>
|
|
||||||
<option value="<?= htmlspecialchars($page) ?>" <?= ($configData['default_page'] ?? '') === $page ? 'selected' : '' ?>>
|
|
||||||
<?= htmlspecialchars(ucwords(str_replace(['-', '_'], ' ', $page))) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<small class="form-text text-muted">Welke pagina wordt getoond als bezoekers de site openen.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-translate"></i> Taal
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="language_default" class="form-label">Standaard taal</label>
|
|
||||||
<select name="language_default" id="language_default" class="form-select">
|
|
||||||
<?php foreach (['nl' => 'Nederlands', 'en' => 'English'] as $code => $label): ?>
|
|
||||||
<option value="<?= $code ?>" <?= ($configData['language']['default'] ?? 'nl') === $code ? 'selected' : '' ?>>
|
|
||||||
<?= $label ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label">Beschikbare talen</label>
|
|
||||||
<div>
|
|
||||||
<?php $availLangs = $configData['language']['available'] ?? ['nl', 'en']; ?>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input type="checkbox" class="form-check-input" id="lang_nl" name="language_available[]" value="nl" <?= in_array('nl', $availLangs) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label" for="lang_nl">Nederlands</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input type="checkbox" class="form-check-input" id="lang_en" name="language_available[]" value="en" <?= in_array('en', $availLangs) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label" for="lang_en">English</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-search"></i> SEO
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="seo_description" class="form-label">Meta beschrijving</label>
|
|
||||||
<textarea class="form-control" id="seo_description" name="seo_description" rows="2"><?= htmlspecialchars($configData['seo']['description'] ?? '') ?></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="seo_keywords" class="form-label">Meta keywords</label>
|
|
||||||
<input type="text" class="form-control" id="seo_keywords" name="seo_keywords"
|
|
||||||
value="<?= htmlspecialchars($configData['seo']['keywords'] ?? '') ?>">
|
|
||||||
<small class="form-text text-muted">Komma-gescheiden trefwoorden.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-person"></i> Auteur
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="author_name" class="form-label">Naam</label>
|
|
||||||
<input type="text" class="form-control" id="author_name" name="author_name"
|
|
||||||
value="<?= htmlspecialchars($configData['author']['name'] ?? '') ?>">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="author_website" class="form-label">Website</label>
|
|
||||||
<input type="url" class="form-control" id="author_website" name="author_website"
|
|
||||||
value="<?= htmlspecialchars($configData['author']['website'] ?? '') ?>">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-toggle-on"></i> Features
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-check mb-2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="feature_auto_link" name="feature_auto_link" value="1" <?= !empty($configData['features']['auto_link_pages']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label" for="feature_auto_link">Automatisch pagina's linken</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check mb-2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="feature_search" name="feature_search" value="1" <?= !empty($configData['features']['search_enabled']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label" for="feature_search">Zoekfunctie inschakelen</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check mb-2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="feature_breadcrumbs" name="feature_breadcrumbs" value="1" <?= !empty($configData['features']['breadcrumbs_enabled']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label" for="feature_breadcrumbs">Breadcrumbs tonen</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="show_version" name="show_version" value="1" <?= !empty($configData['show_version']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label" for="show_version">CMS versie tonen in footer</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg">
|
|
||||||
<i class="bi bi-check-lg"></i> Configuratie opslaan
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> Map hernoemen</h2>
|
|
||||||
|
|
||||||
<form method="post" class="card shadow-sm">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label text-muted">Huidige mapnaam</label>
|
|
||||||
<p class="form-control-plaintext fw-bold"><?= htmlspecialchars(basename($fullPath)) ?></p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_name" class="form-label">Nieuwe naam</label>
|
|
||||||
<input type="text" class="form-control" id="new_name" name="new_name"
|
|
||||||
value="<?= htmlspecialchars(basename($fullPath)) ?>" required autofocus>
|
|
||||||
<div class="form-text">Alleen letters, cijfers, punten, underscores en streepjes.</div>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($message)): ?>
|
|
||||||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer text-end">
|
|
||||||
<a href="/admin/content?dir=<?= urlencode(dirname($dir) === '.' ? '' : dirname($dir)) ?>" class="btn btn-secondary">Annuleren</a>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Opslaan</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> <?= htmlspecialchars($fileName) ?></h2>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="/admin/content-edit?file=<?= urlencode($file) ?>" id="editor-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="filename" class="form-label">Bestandsnaam</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" id="filename" name="filename"
|
|
||||||
value="<?= htmlspecialchars(pathinfo($fileName, PATHINFO_FILENAME)) ?>" required>
|
|
||||||
<span class="input-group-text">.<?= htmlspecialchars($fileExt) ?></span>
|
|
||||||
</div>
|
|
||||||
<small class="form-text text-muted">Alleen letters, cijfers, punten, underscores en streepjes.</small>
|
|
||||||
</div>
|
|
||||||
<?php if ($isEditable): ?>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="layout" class="form-label">Sjabloon / Layout</label>
|
|
||||||
<select class="form-select" id="layout" name="layout">
|
|
||||||
<option value="sidebar-content" <?= $currentLayout === 'sidebar-content' ? 'selected' : '' ?>>Sidebar + Inhoud (standaard)</option>
|
|
||||||
<option value="content" <?= $currentLayout === 'content' ? 'selected' : '' ?>>Alleen inhoud (full-width)</option>
|
|
||||||
<option value="sidebar" <?= $currentLayout === 'sidebar' ? 'selected' : '' ?>>Alleen sidebar (full-width)</option>
|
|
||||||
<option value="content-sidebar" <?= $currentLayout === 'content-sidebar' ? 'selected' : '' ?>>Inhoud links + sidebar rechts</option>
|
|
||||||
<option value="content-sidebar-reverse" <?= $currentLayout === 'content-sidebar-reverse' ? 'selected' : '' ?>>Inhoud rechts + sidebar links</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($availablePlugins)): ?>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label d-block">Zichtbare plugins</label>
|
|
||||||
<div class="d-flex flex-wrap gap-1">
|
|
||||||
<?php foreach ($availablePlugins as $plugin): ?>
|
|
||||||
<input type="checkbox" class="btn-check" id="plugin-<?= $plugin ?>" name="plugins[]" value="<?= htmlspecialchars($plugin) ?>" autocomplete="off" <?= in_array($plugin, $selectedPlugins) ? 'checked' : '' ?>>
|
|
||||||
<label class="btn btn-outline-primary btn-sm" for="plugin-<?= $plugin ?>"><?= htmlspecialchars($plugin) ?></label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php if ($isEditable): ?>
|
|
||||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
|
||||||
<div class="editor-wrapper">
|
|
||||||
<textarea name="content" id="editor-textarea" data-ext="<?= $fileExt ?>"><?= htmlspecialchars($fileContent) ?></textarea>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div class="d-flex gap-2 mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary" title="Opslaan (Ctrl+S)">
|
|
||||||
<i class="bi bi-check-lg"></i> Opslaan
|
|
||||||
</button>
|
|
||||||
<?php if ($isEditable): ?>
|
|
||||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars(pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME)) ?>" target="_blank" class="btn btn-outline-info" title="Open in nieuw tabblad">
|
|
||||||
<i class="bi bi-eye"></i> Preview
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<a href="/admin/content?dir=<?= urlencode(dirname($file)) ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var backBtn = document.getElementById('back-btn');
|
|
||||||
var filenameInput = document.getElementById('filename');
|
|
||||||
if (!backBtn) return;
|
|
||||||
|
|
||||||
var changed = false;
|
|
||||||
|
|
||||||
function markChanged() {
|
|
||||||
if (changed) return;
|
|
||||||
changed = true;
|
|
||||||
backBtn.innerHTML = '<i class="bi bi-x-circle"></i> Annuleren';
|
|
||||||
backBtn.classList.remove('btn-outline-secondary');
|
|
||||||
backBtn.classList.add('btn-outline-danger');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filenameInput) {
|
|
||||||
filenameInput.addEventListener('input', markChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.__onContentChange = markChanged;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<?php if ($isEditable): ?>
|
|
||||||
<!-- Media Modal -->
|
|
||||||
<div class="modal fade" id="mediaModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-images"></i> Media</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="collapse mb-3" id="mediaUploadForm">
|
|
||||||
<div class="card card-body">
|
|
||||||
<form id="media-upload-form" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<div class="mb-2">
|
|
||||||
<input type="file" class="form-control" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,video/mp4,video/webm,audio/mpeg,audio/wav" id="media-file-input">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success btn-sm" id="media-upload-btn" disabled>
|
|
||||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#mediaUploadForm">
|
|
||||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
|
||||||
</button>
|
|
||||||
<small class="text-muted" id="media-count"></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Media grid -->
|
|
||||||
<div id="media-grid" class="row g-2">
|
|
||||||
<div class="col-12 text-center text-muted py-4">
|
|
||||||
<div class="spinner-border spinner-border-sm me-2"></div> Laden...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Size form (shown when clicking an image) -->
|
|
||||||
<div id="media-size-form" class="d-none">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex align-items-center gap-3 mb-3">
|
|
||||||
<img id="size-preview" src="" alt="" style="width:80px;height:60px;object-fit:cover;border-radius:4px;">
|
|
||||||
<div>
|
|
||||||
<strong id="size-filename" class="d-block"></strong>
|
|
||||||
<small class="text-muted">Geef de gewenste afmetingen (optioneel)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-2 mb-3">
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label small">Breedte (px)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="size-width" placeholder="auto" min="1">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label small">Hoogte (px)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="size-height" placeholder="auto" min="1">
|
|
||||||
</div>
|
|
||||||
<div class="col-4 d-flex align-items-end gap-1">
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="size-insert-btn">
|
|
||||||
<i class="bi bi-check-lg"></i> Invoegen
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="size-cancel-btn">
|
|
||||||
Annuleren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var mediaModal = document.getElementById('mediaModal');
|
|
||||||
if (!mediaModal) return;
|
|
||||||
|
|
||||||
var ext = document.getElementById('editor-textarea').dataset.ext || 'md';
|
|
||||||
var mode = ext === 'md' ? 'markdown' : 'html';
|
|
||||||
var pendingFile = null;
|
|
||||||
|
|
||||||
function getCurrentMode() {
|
|
||||||
var ta = document.getElementById('editor-textarea');
|
|
||||||
if (!ta) return 'html';
|
|
||||||
var ext = ta.dataset.ext || 'md';
|
|
||||||
return ext === 'md' ? 'markdown' : 'html';
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaModal.addEventListener('show.bs.modal', function () {
|
|
||||||
document.getElementById('media-size-form').classList.add('d-none');
|
|
||||||
document.getElementById('media-grid').classList.remove('d-none');
|
|
||||||
pendingFile = null;
|
|
||||||
fetch('/admin/media-list')
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (files) {
|
|
||||||
var grid = document.getElementById('media-grid');
|
|
||||||
document.getElementById('media-count').textContent = files.length + ' bestand(en)';
|
|
||||||
if (files.length === 0) {
|
|
||||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">Geen media bestanden gevonden.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
grid.innerHTML = '';
|
|
||||||
files.forEach(function (f) {
|
|
||||||
var col = document.createElement('div');
|
|
||||||
col.className = 'col-6 col-md-4 col-lg-3';
|
|
||||||
var card = document.createElement('div');
|
|
||||||
card.className = 'card card-media-item';
|
|
||||||
card.style.cursor = 'pointer';
|
|
||||||
card.title = 'Klik om in te voegen';
|
|
||||||
|
|
||||||
var preview;
|
|
||||||
if (f.is_image) {
|
|
||||||
preview = '<img src="' + f.url + '" alt="' + f.name + '" class="card-img-top" style="height:100px;object-fit:cover;">';
|
|
||||||
} else if (f.is_video) {
|
|
||||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-film fs-1 text-muted"></i></div>';
|
|
||||||
} else if (f.is_audio) {
|
|
||||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-music-note-beamed fs-1 text-muted"></i></div>';
|
|
||||||
} else {
|
|
||||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><span class="badge bg-secondary fs-5">' + f.ext.toUpperCase() + '</span></div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
card.innerHTML = preview +
|
|
||||||
'<div class="card-body p-2"><small class="text-truncate d-block">' + f.name + '</small></div>';
|
|
||||||
|
|
||||||
card.addEventListener('click', function () {
|
|
||||||
var m = getCurrentMode();
|
|
||||||
if (f.is_image && m !== 'markdown') {
|
|
||||||
showSizeForm(f);
|
|
||||||
} else {
|
|
||||||
insertMedia(f, m);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
col.appendChild(card);
|
|
||||||
grid.appendChild(col);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
document.getElementById('media-grid').innerHTML = '<div class="col-12 text-center text-danger py-4">Fout bij laden van media.</div>';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function showSizeForm(f) {
|
|
||||||
pendingFile = f;
|
|
||||||
document.getElementById('media-grid').classList.add('d-none');
|
|
||||||
document.getElementById('media-size-form').classList.remove('d-none');
|
|
||||||
document.getElementById('size-preview').src = f.url;
|
|
||||||
document.getElementById('size-filename').textContent = f.name;
|
|
||||||
document.getElementById('size-width').value = '';
|
|
||||||
document.getElementById('size-height').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('size-insert-btn').addEventListener('click', function () {
|
|
||||||
if (!pendingFile) return;
|
|
||||||
var w = document.getElementById('size-width').value;
|
|
||||||
var h = document.getElementById('size-height').value;
|
|
||||||
insertMedia(pendingFile, getCurrentMode(), w, h);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('size-cancel-btn').addEventListener('click', function () {
|
|
||||||
document.getElementById('media-size-form').classList.add('d-none');
|
|
||||||
document.getElementById('media-grid').classList.remove('d-none');
|
|
||||||
pendingFile = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function insertMedia(f, mode, w, h) {
|
|
||||||
var editorEl = document.querySelector('.CodeMirror');
|
|
||||||
if (!editorEl || typeof CodeMirror === 'undefined') return;
|
|
||||||
var cm = editorEl.CodeMirror;
|
|
||||||
if (!cm) return;
|
|
||||||
|
|
||||||
var sizeAttr = '';
|
|
||||||
if (w || h) {
|
|
||||||
if (w) sizeAttr += ' width="' + parseInt(w) + '"';
|
|
||||||
if (h) sizeAttr += ' height="' + parseInt(h) + '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
var tag;
|
|
||||||
if (mode === 'markdown') {
|
|
||||||
if (f.is_image) {
|
|
||||||
tag = '';
|
|
||||||
} else {
|
|
||||||
tag = '[' + f.name + '](' + f.url + ')';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (f.is_image) {
|
|
||||||
tag = '<img src="' + f.url + '" alt="' + f.name + '"' + sizeAttr + '>';
|
|
||||||
} else if (f.is_video) {
|
|
||||||
tag = '<video controls src="' + f.url + '" style="max-width:100%;"></video>';
|
|
||||||
} else if (f.is_audio) {
|
|
||||||
tag = '<audio controls src="' + f.url + '"></audio>';
|
|
||||||
} else {
|
|
||||||
tag = '<a href="' + f.url + '">' + f.name + '</a>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cm.replaceSelection(tag);
|
|
||||||
cm.focus();
|
|
||||||
|
|
||||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
|
||||||
if (modal) modal.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle upload
|
|
||||||
document.getElementById('media-upload-form').addEventListener('submit', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var form = this;
|
|
||||||
var formData = new FormData(form);
|
|
||||||
formData.append('csrf_token', '<?= $csrf ?>');
|
|
||||||
|
|
||||||
fetch('/admin/media', { method: 'POST', body: formData })
|
|
||||||
.then(function () {
|
|
||||||
form.reset();
|
|
||||||
document.getElementById('media-upload-btn').disabled = true;
|
|
||||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
|
||||||
if (modal) modal.hide();
|
|
||||||
setTimeout(function () { modal.show(); }, 100);
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
alert('Upload mislukt.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('media-file-input').addEventListener('change', function () {
|
|
||||||
document.getElementById('media-upload-btn').disabled = this.files.length === 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-arrows-move"></i> <?= is_file($fullPath) ? 'Bestand' : 'Map' ?> verplaatsen</h2>
|
|
||||||
|
|
||||||
<form method="post" class="card shadow-sm">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label text-muted">Te verplaatsen item</label>
|
|
||||||
<p class="form-control-plaintext fw-bold">
|
|
||||||
<i class="bi <?= is_file($fullPath) ? 'bi-file' : 'bi-folder' ?>"></i>
|
|
||||||
<?= htmlspecialchars(basename($fullPath)) ?>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="target_dir" class="form-label">Doelmap</label>
|
|
||||||
<select class="form-select" id="target_dir" name="target_dir" required>
|
|
||||||
<option value="">-- Selecteer doelmap --</option>
|
|
||||||
<option value="">/ (hoofdmap)</option>
|
|
||||||
<?php foreach ($dirs as $d): ?>
|
|
||||||
<option value="<?= htmlspecialchars($d) ?>"><?= htmlspecialchars($d) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<div class="form-text">Selecteer de map waar het item naartoe verplaatst moet worden.</div>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($message)): ?>
|
|
||||||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer text-end">
|
|
||||||
<a href="/admin/content?dir=<?= urlencode(dirname($item) === '.' ? '' : dirname($item)) ?>" class="btn btn-secondary">Annuleren</a>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Verplaatsen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-plus-lg"></i> Nieuwe pagina</h2>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="/admin/content-new?dir=<?= urlencode($dir ?? '') ?>" id="editor-form" data-new-page>
|
|
||||||
<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" data-editor-mode>
|
|
||||||
<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="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="layout" class="form-label">Sjabloon / Layout</label>
|
|
||||||
<select class="form-select" id="layout" name="layout">
|
|
||||||
<option value="sidebar-content">Sidebar + Inhoud (standaard)</option>
|
|
||||||
<option value="content">Alleen inhoud (full-width)</option>
|
|
||||||
<option value="sidebar">Alleen sidebar (full-width)</option>
|
|
||||||
<option value="content-sidebar">Inhoud links + sidebar rechts</option>
|
|
||||||
<option value="content-sidebar-reverse">Inhoud rechts + sidebar links</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($availablePlugins)): ?>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label d-block">Zichtbare plugins</label>
|
|
||||||
<div class="d-flex flex-wrap gap-1">
|
|
||||||
<?php foreach ($availablePlugins as $plugin): ?>
|
|
||||||
<input type="checkbox" class="btn-check" id="plugin-<?= $plugin ?>" name="plugins[]" value="<?= htmlspecialchars($plugin) ?>" autocomplete="off" checked>
|
|
||||||
<label class="btn btn-outline-primary btn-sm" for="plugin-<?= $plugin ?>"><?= htmlspecialchars($plugin) ?></label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
|
||||||
<div class="editor-wrapper">
|
|
||||||
<textarea name="content" id="editor-textarea" data-ext="md"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary" id="content-create-btn" disabled>
|
|
||||||
<i class="bi bi-check-lg"></i> Aanmaken
|
|
||||||
</button>
|
|
||||||
<a href="/admin/content?dir=<?= urlencode($dir ?? '') ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Media Modal -->
|
|
||||||
<div class="modal fade" id="mediaModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-images"></i> Media</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="collapse mb-3" id="mediaUploadForm">
|
|
||||||
<div class="card card-body">
|
|
||||||
<form id="media-upload-form" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<div class="mb-2">
|
|
||||||
<input type="file" class="form-control" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,video/mp4,video/webm,audio/mpeg,audio/wav" id="media-file-input">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success btn-sm" id="media-upload-btn" disabled>
|
|
||||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#mediaUploadForm">
|
|
||||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
|
||||||
</button>
|
|
||||||
<small class="text-muted" id="media-count"></small>
|
|
||||||
</div>
|
|
||||||
<div id="media-grid" class="row g-2">
|
|
||||||
<div class="col-12 text-center text-muted py-4">
|
|
||||||
<div class="spinner-border spinner-border-sm me-2"></div> Laden...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Size form (shown when clicking an image) -->
|
|
||||||
<div id="media-size-form" class="d-none">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex align-items-center gap-3 mb-3">
|
|
||||||
<img id="size-preview" src="" alt="" style="width:80px;height:60px;object-fit:cover;border-radius:4px;">
|
|
||||||
<div>
|
|
||||||
<strong id="size-filename" class="d-block"></strong>
|
|
||||||
<small class="text-muted">Geef de gewenste afmetingen (optioneel)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-2 mb-3">
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label small">Breedte (px)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="size-width" placeholder="auto" min="1">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label small">Hoogte (px)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="size-height" placeholder="auto" min="1">
|
|
||||||
</div>
|
|
||||||
<div class="col-4 d-flex align-items-end gap-1">
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="size-insert-btn">
|
|
||||||
<i class="bi bi-check-lg"></i> Invoegen
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="size-cancel-btn">
|
|
||||||
Annuleren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var backBtn = document.getElementById('back-btn');
|
|
||||||
var filenameInput = document.getElementById('filename');
|
|
||||||
var createBtn = document.getElementById('content-create-btn');
|
|
||||||
|
|
||||||
if (backBtn) {
|
|
||||||
var changed = false;
|
|
||||||
function markChanged() {
|
|
||||||
if (changed) return;
|
|
||||||
changed = true;
|
|
||||||
backBtn.innerHTML = '<i class="bi bi-x-circle"></i> Annuleren';
|
|
||||||
backBtn.classList.remove('btn-outline-secondary');
|
|
||||||
backBtn.classList.add('btn-outline-danger');
|
|
||||||
}
|
|
||||||
if (filenameInput) {
|
|
||||||
filenameInput.addEventListener('input', markChanged);
|
|
||||||
}
|
|
||||||
window.__onContentChange = markChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filenameInput && createBtn) {
|
|
||||||
filenameInput.addEventListener('input', function () {
|
|
||||||
createBtn.disabled = this.value.trim() === '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaModal = document.getElementById('mediaModal');
|
|
||||||
if (!mediaModal) return;
|
|
||||||
|
|
||||||
function getCurrentMode() {
|
|
||||||
var ta = document.getElementById('editor-textarea');
|
|
||||||
var ext = ta ? ta.dataset.ext || 'md' : 'md';
|
|
||||||
return ext === 'md' ? 'markdown' : 'html';
|
|
||||||
}
|
|
||||||
|
|
||||||
var pendingFile = null;
|
|
||||||
|
|
||||||
mediaModal.addEventListener('show.bs.modal', function () {
|
|
||||||
document.getElementById('media-size-form').classList.add('d-none');
|
|
||||||
document.getElementById('media-grid').classList.remove('d-none');
|
|
||||||
pendingFile = null;
|
|
||||||
fetch('/admin/media-list')
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (files) {
|
|
||||||
var grid = document.getElementById('media-grid');
|
|
||||||
document.getElementById('media-count').textContent = files.length + ' bestand(en)';
|
|
||||||
if (files.length === 0) {
|
|
||||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">Geen media bestanden gevonden.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
grid.innerHTML = '';
|
|
||||||
files.forEach(function (f) {
|
|
||||||
var col = document.createElement('div');
|
|
||||||
col.className = 'col-6 col-md-4 col-lg-3';
|
|
||||||
var card = document.createElement('div');
|
|
||||||
card.className = 'card card-media-item';
|
|
||||||
card.style.cursor = 'pointer';
|
|
||||||
card.title = 'Klik om in te voegen';
|
|
||||||
|
|
||||||
var preview;
|
|
||||||
if (f.is_image) {
|
|
||||||
preview = '<img src="' + f.url + '" alt="' + f.name + '" class="card-img-top" style="height:100px;object-fit:cover;">';
|
|
||||||
} else if (f.is_video) {
|
|
||||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-film fs-1 text-muted"></i></div>';
|
|
||||||
} else if (f.is_audio) {
|
|
||||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-music-note-beamed fs-1 text-muted"></i></div>';
|
|
||||||
} else {
|
|
||||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><span class="badge bg-secondary fs-5">' + f.ext.toUpperCase() + '</span></div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
card.innerHTML = preview +
|
|
||||||
'<div class="card-body p-2"><small class="text-truncate d-block">' + f.name + '</small></div>';
|
|
||||||
|
|
||||||
card.addEventListener('click', function () {
|
|
||||||
var mode = getCurrentMode();
|
|
||||||
if (f.is_image && mode !== 'markdown') {
|
|
||||||
showSizeForm(f);
|
|
||||||
} else {
|
|
||||||
insertMedia(f, mode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
col.appendChild(card);
|
|
||||||
grid.appendChild(col);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
document.getElementById('media-grid').innerHTML = '<div class="col-12 text-center text-danger py-4">Fout bij laden van media.</div>';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function showSizeForm(f) {
|
|
||||||
pendingFile = f;
|
|
||||||
document.getElementById('media-grid').classList.add('d-none');
|
|
||||||
document.getElementById('media-size-form').classList.remove('d-none');
|
|
||||||
document.getElementById('size-preview').src = f.url;
|
|
||||||
document.getElementById('size-filename').textContent = f.name;
|
|
||||||
document.getElementById('size-width').value = '';
|
|
||||||
document.getElementById('size-height').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('size-insert-btn').addEventListener('click', function () {
|
|
||||||
if (!pendingFile) return;
|
|
||||||
var w = document.getElementById('size-width').value;
|
|
||||||
var h = document.getElementById('size-height').value;
|
|
||||||
insertMedia(pendingFile, getCurrentMode(), w, h);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('size-cancel-btn').addEventListener('click', function () {
|
|
||||||
document.getElementById('media-size-form').classList.add('d-none');
|
|
||||||
document.getElementById('media-grid').classList.remove('d-none');
|
|
||||||
pendingFile = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function insertMedia(f, mode, w, h) {
|
|
||||||
var editorEl = document.querySelector('.CodeMirror');
|
|
||||||
if (!editorEl || typeof CodeMirror === 'undefined') return;
|
|
||||||
var cm = editorEl.CodeMirror;
|
|
||||||
if (!cm) return;
|
|
||||||
|
|
||||||
var sizeAttr = '';
|
|
||||||
if (w || h) {
|
|
||||||
if (w) sizeAttr += ' width="' + parseInt(w) + '"';
|
|
||||||
if (h) sizeAttr += ' height="' + parseInt(h) + '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
var tag;
|
|
||||||
if (mode === 'markdown') {
|
|
||||||
if (f.is_image) {
|
|
||||||
tag = '';
|
|
||||||
} else {
|
|
||||||
tag = '[' + f.name + '](' + f.url + ')';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (f.is_image) {
|
|
||||||
tag = '<img src="' + f.url + '" alt="' + f.name + '"' + sizeAttr + '>';
|
|
||||||
} else if (f.is_video) {
|
|
||||||
tag = '<video controls src="' + f.url + '" style="max-width:100%;"></video>';
|
|
||||||
} else if (f.is_audio) {
|
|
||||||
tag = '<audio controls src="' + f.url + '"></audio>';
|
|
||||||
} else {
|
|
||||||
tag = '<a href="' + f.url + '">' + f.name + '</a>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cm.replaceSelection(tag);
|
|
||||||
cm.focus();
|
|
||||||
|
|
||||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
|
||||||
if (modal) modal.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('media-upload-form').addEventListener('submit', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var form = this;
|
|
||||||
var formData = new FormData(form);
|
|
||||||
formData.append('csrf_token', '<?= $csrf ?>');
|
|
||||||
|
|
||||||
fetch('/admin/media', { method: 'POST', body: formData })
|
|
||||||
.then(function () {
|
|
||||||
form.reset();
|
|
||||||
document.getElementById('media-upload-btn').disabled = true;
|
|
||||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
|
||||||
if (modal) modal.hide();
|
|
||||||
setTimeout(function () { modal.show(); }, 100);
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
alert('Upload mislukt.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('media-file-input').addEventListener('change', function () {
|
|
||||||
document.getElementById('media-upload-btn').disabled = this.files.length === 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> Dashboard</h2>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$aTotals = $analyticsSummary['totals'] ?? [];
|
|
||||||
$aCountries = $analyticsSummary['countries'] ?? [];
|
|
||||||
$topCountry = null;
|
|
||||||
foreach ($aCountries as $cc => $cnt) {
|
|
||||||
if ($cc !== 'UNKNOWN') { $topCountry = $cc; break; }
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<div class="row g-4 mb-4">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<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">Weergaven (30 dagen)</h6>
|
|
||||||
<h3 class="mb-0"><?= number_format((int)($aTotals['views'] ?? 0), 0, ',', '.') ?></h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-eye stat-icon text-primary"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<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">Unieke bezoekers (30 dagen)</h6>
|
|
||||||
<h3 class="mb-0"><?= number_format((int)($aTotals['uniques'] ?? 0), 0, ',', '.') ?></h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-people stat-icon text-success"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<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">Grootste land</h6>
|
|
||||||
<h3 class="mb-0">
|
|
||||||
<?= GeoIP::getCountryFlagEmoji($topCountry) ?>
|
|
||||||
<span class="fs-5"><?= htmlspecialchars(GeoIP::getCountryName($topCountry)) ?></span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<a href="/admin/statistics" class="btn btn-sm btn-outline-secondary">Statistieken →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">CodePress versie</td><td><?= htmlspecialchars($stats['cms_version']) ?></td></tr>
|
|
||||||
<tr><td class="text-muted">PHP versie</td><td><?= $stats['php_version'] ?></td></tr>
|
|
||||||
<tr><td class="text-muted">Besturingssysteem</td><td><?= htmlspecialchars($stats['os']) ?></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 d-flex justify-content-between align-items-center">
|
|
||||||
<span><i class="bi bi-activity"></i> Recente activiteit</span>
|
|
||||||
<a href="/admin/logs?tab=admin" class="btn btn-sm btn-outline-secondary">Bekijk alle →</a>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
|
||||||
<?php if (empty($recentLogs)): ?>
|
|
||||||
<p class="text-muted mb-0">Geen activiteit geregistreerd.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
<?php foreach ($recentLogs as $log): ?>
|
|
||||||
<li class="mb-2 pb-2 border-bottom small">
|
|
||||||
<span class="text-muted"><?= htmlspecialchars($log['time']) ?></span>
|
|
||||||
<span class="badge bg-<?= $log['level'] === 'warning' ? 'warning text-dark' : ($log['level'] === 'error' ? 'danger' : 'info') ?> me-1"><?= htmlspecialchars($log['level']) ?></span>
|
|
||||||
<code class="text-muted"><?= htmlspecialchars($log['ip']) ?></code>
|
|
||||||
<?= htmlspecialchars($log['message']) ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<span><i class="bi bi-globe"></i> Recente requests</span>
|
|
||||||
<a href="/admin/logs?tab=requests" class="btn btn-sm btn-outline-secondary">Bekijk alle →</a>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
|
||||||
<?php if (empty($recentRequests)): ?>
|
|
||||||
<p class="text-muted mb-0">Geen requests geregistreerd.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
<?php foreach ($recentRequests as $log): ?>
|
|
||||||
<li class="mb-2 pb-2 border-bottom small d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<span class="text-muted me-1"><?= htmlspecialchars($log['time']) ?></span>
|
|
||||||
<code class="text-muted me-1"><?= htmlspecialchars($log['ip']) ?></code>
|
|
||||||
<span class="fw-bold"><?= htmlspecialchars($log['page']) ?></span>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($log['visitor_info'])): ?>
|
|
||||||
<span class="badge bg-<?= $log['visitor_info']['badge'] ?>" title="<?= htmlspecialchars($log['ua']) ?>">
|
|
||||||
<i class="bi <?= $log['visitor_info']['icon'] ?>"></i> <?= $log['visitor_info']['label'] ?>
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</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/content-new" class="btn btn-outline-primary"><i class="bi bi-plus-lg"></i> Nieuwe pagina</a>
|
|
||||||
<a href="/admin/config" class="btn btn-outline-secondary"><i class="bi bi-sliders"></i> Configuratie bewerken</a>
|
|
||||||
<a href="/admin/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>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-book"></i> Handleiding</h2>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="btn-group" role="group">
|
|
||||||
<a href="/admin/guide?lang=nl" class="btn btn-sm <?= $lang === 'nl' ? 'btn-primary' : 'btn-outline-primary' ?>">Nederlands</a>
|
|
||||||
<a href="/admin/guide?lang=en" class="btn btn-sm <?= $lang === 'en' ? 'btn-primary' : 'btn-outline-primary' ?>">English</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body guide-content">
|
|
||||||
<?= $content ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.guide-content h2 { margin-top: 1.5rem; }
|
|
||||||
.guide-content h3 { margin-top: 1.25rem; }
|
|
||||||
.guide-content pre { background: #f8f9fa; padding: 1rem; border-radius: 6px; overflow-x: auto; border: 1px solid #dee2e6; margin-bottom: 1rem; }
|
|
||||||
.guide-content pre code { background: none; padding: 0; color: #333; font-size: 0.85rem; line-height: 1.5; }
|
|
||||||
.guide-content code { background: #e8e8e8; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; color: #d63384; }
|
|
||||||
.guide-content table { width: 100%; margin-bottom: 1rem; }
|
|
||||||
.guide-content table th, .guide-content table td { padding: 0.5rem; border: 1px solid #dee2e6; }
|
|
||||||
.guide-content blockquote { border-left: 3px solid #ccc; padding-left: 1rem; color: #666; margin-left: 0; }
|
|
||||||
.guide-content .heading-permalink { display: none; }
|
|
||||||
.guide-content ul:first-of-type { list-style: none; padding-left: 0; }
|
|
||||||
.guide-content ul:first-of-type li { padding: 0.15rem 0; }
|
|
||||||
.guide-content ul:first-of-type li a { text-decoration: none; color: #0d6efd; }
|
|
||||||
.guide-content ul:first-of-type li a:hover { text-decoration: underline; }
|
|
||||||
</style>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-journal-text"></i> Logs</h2>
|
|
||||||
|
|
||||||
<ul class="nav nav-tabs mb-3" id="logTabs" role="tablist">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link <?= $activeTab === 'admin' ? 'active' : '' ?>" id="admin-log-tab" data-bs-toggle="tab" data-bs-target="#admin-log" type="button" role="tab">Activiteiten log</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link <?= $activeTab === 'requests' ? 'active' : '' ?>" id="request-log-tab" data-bs-toggle="tab" data-bs-target="#request-log" type="button" role="tab">Requests log</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="tab-content">
|
|
||||||
<!-- Admin activity log -->
|
|
||||||
<div class="tab-pane fade <?= $activeTab === 'admin' ? 'show active' : '' ?>" id="admin-log" role="tabpanel">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<small class="text-muted"><?= count($adminLogs) ?> regels</small>
|
|
||||||
<div>
|
|
||||||
<a href="/admin/logs?tab=admin&download=1" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download"></i> Download</a>
|
|
||||||
<a href="/admin/logs?tab=admin&clear=1" class="btn btn-sm btn-outline-danger" onclick="return confirm('Activiteiten log wissen?')"><i class="bi bi-trash"></i> Wissen</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
|
||||||
<?php if (empty($adminLogs)): ?>
|
|
||||||
<p class="text-muted p-3 mb-0">Geen activiteiten geregistreerd.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<table class="table table-sm table-hover mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Tijd</th>
|
|
||||||
<th>Niveau</th>
|
|
||||||
<th>IP</th>
|
|
||||||
<th>Bericht</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($adminLogs as $log): ?>
|
|
||||||
<tr>
|
|
||||||
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
|
|
||||||
<td><span class="badge bg-<?= $log['level'] === 'warning' ? 'warning text-dark' : ($log['level'] === 'error' ? 'danger' : 'info') ?>"><?= htmlspecialchars($log['level']) ?></span></td>
|
|
||||||
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
|
|
||||||
<td><?= htmlspecialchars($log['message']) ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Request log -->
|
|
||||||
<div class="tab-pane fade <?= $activeTab === 'requests' ? 'show active' : '' ?>" id="request-log" role="tabpanel">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<small class="text-muted"><?= count($requestLogs) ?> regels</small>
|
|
||||||
<div>
|
|
||||||
<a href="/admin/logs?tab=requests&download=1" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download"></i> Download</a>
|
|
||||||
<a href="/admin/logs?tab=requests&clear=1" class="btn btn-sm btn-outline-danger" onclick="return confirm('Requestlog wissen?')"><i class="bi bi-trash"></i> Wissen</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
|
||||||
<?php if (empty($requestLogs)): ?>
|
|
||||||
<p class="text-muted p-3 mb-0">Geen requests geregistreerd.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<table class="table table-sm table-hover mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Tijd</th>
|
|
||||||
<th>IP</th>
|
|
||||||
<th>Land</th>
|
|
||||||
<th>Pagina</th>
|
|
||||||
<th>Type / Gebruiker</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Taal</th>
|
|
||||||
<th>User agent</th>
|
|
||||||
<th>Referrer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($requestLogs as $log): ?>
|
|
||||||
<tr class="<?= str_starts_with($log['status'] ?? 'ok', 'blocked') ? 'table-danger' : '' ?>">
|
|
||||||
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
|
|
||||||
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
|
|
||||||
<td class="text-nowrap small" title="<?= htmlspecialchars(GeoIP::getCountryName($log['country'] ?: null)) ?>">
|
|
||||||
<?= GeoIP::getCountryFlagEmoji($log['country'] ?: null) ?>
|
|
||||||
<span class="text-muted"><?= htmlspecialchars($log['country'] ?: '—') ?></span>
|
|
||||||
</td>
|
|
||||||
<td><?= htmlspecialchars($log['page']) ?></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-<?= $log['visitor_info']['badge'] ?>">
|
|
||||||
<i class="bi <?= $log['visitor_info']['icon'] ?>"></i> <?= $log['visitor_info']['label'] ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<?php if (str_starts_with($log['status'] ?? 'ok', 'blocked')): ?>
|
|
||||||
<span class="badge bg-danger" title="<?= htmlspecialchars($log['status']) ?>">
|
|
||||||
<i class="bi bi-shield-x"></i> Geblokkeerd
|
|
||||||
</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="badge bg-success" title="Toegestaan">
|
|
||||||
<i class="bi bi-check-circle"></i> OK
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td><?= htmlspecialchars($log['lang']) ?></td>
|
|
||||||
<td class="small text-muted" title="<?= htmlspecialchars($log['ua']) ?>"><?= htmlspecialchars(substr($log['ua'], 0, 50)) ?><?= strlen($log['ua']) > 50 ? '…' : '' ?></td>
|
|
||||||
<td class="small text-muted" title="<?= htmlspecialchars($log['referrer']) ?>"><?= htmlspecialchars(substr($log['referrer'], 0, 30)) ?><?= strlen($log['referrer']) > 30 ? '…' : '' ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="bi bi-images"></i> Media</h2>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
|
||||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapse mb-4" id="uploadForm">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="/admin/media" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="file" class="form-label">Bestanden selecteren</label>
|
|
||||||
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav">
|
|
||||||
<small class="form-text text-muted">Toegestaan: JPG, PNG, GIF, WebP, SVG, PDF, ZIP, MP4, WebM, MP3, WAV</small>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success">
|
|
||||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (empty($files)): ?>
|
|
||||||
<div class="alert alert-info">Geen bestanden gevonden in de assets map.</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Voorbeeld</th>
|
|
||||||
<th>Bestand</th>
|
|
||||||
<th>Grootte</th>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>URL</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($files as $file): ?>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<?php if ($file['is_image']): ?>
|
|
||||||
<img src="<?= htmlspecialchars($file['url']) ?>" style="width: 60px; height: 40px; object-fit: cover;" class="img-thumbnail">
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="badge bg-secondary fs-6"><?= strtoupper($file['ext']) ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td><strong><?= htmlspecialchars($file['name']) ?></strong></td>
|
|
||||||
<td class="text-muted small"><?= htmlspecialchars($file['size'] > 1048576 ? round($file['size'] / 1048576, 1) . ' MB' : round($file['size'] / 1024, 1) . ' KB') ?></td>
|
|
||||||
<td class="text-muted small"><?= htmlspecialchars($file['modified']) ?></td>
|
|
||||||
<td><code class="small"><?= htmlspecialchars($file['url']) ?></code></td>
|
|
||||||
<td>
|
|
||||||
<form method="POST" action="/admin/media" class="d-inline" onsubmit="return confirm('Weet je zeker dat je '<?= htmlspecialchars($file['name']) ?>' wilt verwijderen?')">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<input type="hidden" name="delete" value="<?= htmlspecialchars($file['name']) ?>">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Plugin Configuratie: <?= htmlspecialchars($pluginName) ?></h2>
|
|
||||||
|
|
||||||
<form method="post" class="card shadow-sm">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<?php if (empty($pluginConfig)): ?>
|
|
||||||
<div class="alert alert-info">Deze plugin heeft geen configureerbare instellingen.</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($pluginConfig as $key => $value): ?>
|
|
||||||
<?= renderConfigField($key, $value) ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (!empty($pluginConfig)): ?>
|
|
||||||
<div class="card-footer text-end">
|
|
||||||
<a href="/admin/plugins" class="btn btn-secondary">Annuleren</a>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Opslaan</button>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-arrow-left"></i> Terug naar plugins
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
function renderConfigField(string $key, $value, string $prefix = ''): string
|
|
||||||
{
|
|
||||||
$name = $prefix ? $prefix . '[' . $key . ']' : 'config[' . $key . ']';
|
|
||||||
$id = 'cfg_' . str_replace(['.', '['], '_', rtrim($name, ']'));
|
|
||||||
$label = ucwords(str_replace('_', ' ', $key));
|
|
||||||
$html = '';
|
|
||||||
|
|
||||||
if (is_bool($value)) {
|
|
||||||
$checked = $value ? 'checked' : '';
|
|
||||||
$html .= '<div class="mb-3 form-check form-switch">';
|
|
||||||
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" value="0">';
|
|
||||||
$html .= '<input class="form-check-input" type="checkbox" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" value="1" ' . $checked . '>';
|
|
||||||
$html .= '<label class="form-check-label" for="' . htmlspecialchars($id) . '">' . htmlspecialchars($label) . '</label>';
|
|
||||||
$html .= '</div>';
|
|
||||||
} elseif (is_numeric($value)) {
|
|
||||||
$step = is_float($value) ? 'step="0.01"' : 'step="1"';
|
|
||||||
$html .= '<div class="mb-3">';
|
|
||||||
$html .= '<label for="' . htmlspecialchars($id) . '" class="form-label">' . htmlspecialchars($label) . '</label>';
|
|
||||||
$html .= '<input type="number" class="form-control" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value) . '" ' . $step . '>';
|
|
||||||
$html .= '</div>';
|
|
||||||
} elseif (is_array($value)) {
|
|
||||||
$html .= '<div class="mb-3">';
|
|
||||||
$html .= '<label class="form-label fw-bold">' . htmlspecialchars($label) . '</label>';
|
|
||||||
$html .= '<div class="card bg-light">';
|
|
||||||
$html .= '<div class="card-body">';
|
|
||||||
foreach ($value as $subKey => $subValue) {
|
|
||||||
$html .= renderConfigField($subKey, $subValue, $name);
|
|
||||||
}
|
|
||||||
$html .= '</div></div></div>';
|
|
||||||
} else {
|
|
||||||
$html .= '<div class="mb-3">';
|
|
||||||
$html .= '<label for="' . htmlspecialchars($id) . '" class="form-label">' . htmlspecialchars($label) . '</label>';
|
|
||||||
|
|
||||||
if (strlen($value) > 80 || str_contains($value, "\n")) {
|
|
||||||
$html .= '<textarea class="form-control font-monospace" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" rows="4">' . htmlspecialchars($value) . '</textarea>';
|
|
||||||
} else {
|
|
||||||
$html .= '<input type="text" class="form-control" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value) . '">';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="bi bi-pencil"></i> Plugin bewerken: <?= htmlspecialchars($pluginName) ?></h2>
|
|
||||||
<a href="/admin/plugins" 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/plugins-edit?plugin=<?= urlencode($pluginName) ?>" id="editor-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<span class="badge bg-secondary">PHP</span>
|
|
||||||
<small class="text-muted"><?= htmlspecialchars($pluginName) ?>/<?= htmlspecialchars($pluginName) ?>.php</small>
|
|
||||||
</div>
|
|
||||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
|
||||||
<div class="editor-wrapper">
|
|
||||||
<textarea name="content" id="editor-textarea" data-ext="php"><?= htmlspecialchars($fileContent) ?></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-lg"></i> Opslaan
|
|
||||||
</button>
|
|
||||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Nieuwe plugin aanmaken</h2>
|
|
||||||
|
|
||||||
<form method="post" class="card shadow-sm">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<?php if (!empty($message)): ?>
|
|
||||||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="plugin_name" class="form-label">Plugin naam</label>
|
|
||||||
<input type="text" class="form-control font-monospace" id="plugin_name" name="plugin_name"
|
|
||||||
value="<?= htmlspecialchars($_POST['plugin_name'] ?? '') ?>" required
|
|
||||||
placeholder="bijv. MijnPlugin">
|
|
||||||
<div class="form-text">Alleen letters, cijfers en underscores. Begin met een hoofdletter. Wordt gebruikt als <strong>mapnaam</strong>, <strong>bestandsnaam</strong> en <strong>class naam</strong>.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="plugin_desc" class="form-label">Omschrijving <small class="text-muted">(optioneel)</small></label>
|
|
||||||
<textarea class="form-control" id="plugin_desc" name="plugin_desc" rows="3"
|
|
||||||
placeholder="Korte omschrijving van wat de plugin doet..."><?= htmlspecialchars($_POST['plugin_desc'] ?? '') ?></textarea>
|
|
||||||
<div class="form-text">Wordt opgeslagen als README.md bij de plugin.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info mb-0">
|
|
||||||
<strong><i class="bi bi-lightbulb"></i> Wat wordt er aangemaakt?</strong>
|
|
||||||
<ul class="mb-0 mt-2">
|
|
||||||
<li><code>plugins/PluginNaam/PluginNaam.php</code> — Hoofdbestand met boilerplate</li>
|
|
||||||
<li><code>plugins/PluginNaam/config.json</code> — Configuratiebestand</li>
|
|
||||||
<li><code>plugins/PluginNaam/README.md</code> — Documentatie (alleen bij omschrijving)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer text-end">
|
|
||||||
<a href="/admin/plugins" class="btn btn-secondary">Annuleren</a>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Aanmaken</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="bi bi-plug"></i> Plugins</h2>
|
|
||||||
<a href="/admin/plugins-new" class="btn btn-primary btn-sm">
|
|
||||||
<i class="bi bi-plus-lg"></i> Nieuwe plugin
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title"><i class="bi bi-info-circle"></i> Plugin Ontwikkelaarshandleiding</h5>
|
|
||||||
<p class="text-muted mb-2">Een plugin moet aan de volgende eisen voldoen om correct te werken:</p>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-folder2-open text-primary me-2 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
<strong>Mapstructuur</strong><br>
|
|
||||||
<code class="small">plugins/Naam/Naam.php</code>
|
|
||||||
<small class="text-muted d-block">Mapnaam en bestandsnaam moeten identiek zijn.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-code-square text-primary me-2 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
<strong>Class naam</strong><br>
|
|
||||||
<code class="small">class PluginNaam</code>
|
|
||||||
<small class="text-muted d-block">De class moet exact dezelfde naam hebben als de map.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-puzzle text-primary me-2 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
<strong>Optionele hooks</strong><br>
|
|
||||||
<code class="small">setAPI(CMSAPI)</code> · <code class="small">getSidebarContent()</code>
|
|
||||||
<small class="text-muted d-block">Voor CMS-toegang en sidebar-weergave.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-gear-wide text-primary me-2 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
<strong>Configuratie (optioneel)</strong><br>
|
|
||||||
<code class="small">config.json</code>
|
|
||||||
<small class="text-muted d-block">Wordt getoond met een configuratieformulier in de admin.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-shield-check text-primary me-2 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
<strong>Beveiliging</strong><br>
|
|
||||||
<code class="small">htmlspecialchars()</code>
|
|
||||||
<small class="text-muted d-block">Altijd output escapen. Volg PSR-12.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-book text-primary me-2 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
<strong>Documentatie (optioneel)</strong><br>
|
|
||||||
<code class="small">README.md</code>
|
|
||||||
<small class="text-muted d-block">Aangeraden voor uitleg over de plugin.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?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>
|
|
||||||
<div>
|
|
||||||
<?php if ($plugin['enabled']): ?>
|
|
||||||
<span class="badge bg-success me-1">Actief</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="badge bg-secondary me-1">Uitgeschakeld</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($plugin['viewable']): ?>
|
|
||||||
<span class="badge bg-info">Zichtbaar</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="badge bg-secondary">Systeem</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</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 class="mt-3 d-flex justify-content-between align-items-center">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<?php if ($plugin['has_main']): ?>
|
|
||||||
<a href="/admin/plugins-edit?plugin=<?= urlencode($plugin['name']) ?>" class="btn btn-outline-secondary" title="Bewerken">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<form method="POST" action="/admin/plugins-toggle?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<button type="submit" class="btn btn-sm <?= $plugin['enabled'] ? 'btn-outline-warning' : 'btn-outline-success' ?>" title="<?= $plugin['enabled'] ? 'Uitschakelen' : 'Activeren' ?>">
|
|
||||||
<i class="bi <?= $plugin['enabled'] ? 'bi-pause-circle' : 'bi-play-circle' ?>"></i> <?= $plugin['enabled'] ? 'Uitschakelen' : 'Activeren' ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form method="POST" action="/admin/plugins-toggle-visibility?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<button type="submit" class="btn btn-sm <?= $plugin['viewable'] ? 'btn-outline-secondary' : 'btn-outline-info' ?>" title="<?= $plugin['viewable'] ? 'Verbergen' : 'Tonen' ?>">
|
|
||||||
<i class="bi <?= $plugin['viewable'] ? 'bi-eye-slash' : 'bi-eye' ?>"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php if ($plugin['has_config']): ?>
|
|
||||||
<a href="/admin/plugins-config?plugin=<?= urlencode($plugin['name']) ?>" class="btn btn-outline-primary" title="Configureren">
|
|
||||||
<i class="bi bi-gear"></i>
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/admin/plugins-delete?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je de plugin '<?= htmlspecialchars($plugin['name']) ?>' wilt verwijderen? Alle bestanden worden permanent verwijderd.')">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-shield-check"></i> Beveiliging & Bot Bescherming</h2>
|
|
||||||
|
|
||||||
<form method="POST" action="/admin/security">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header bg-dark text-white">
|
|
||||||
<i class="bi bi-robot"></i> Bot, AI & Scraper Blokkering
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="block_ai_bots" name="block_ai_bots" value="1" <?= !empty($sec['block_ai_bots']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label fw-bold" for="block_ai_bots">
|
|
||||||
<i class="bi bi-cpu text-danger"></i> AI Crawlers & Scrapers blokkeren (403 Forbidden)
|
|
||||||
</label>
|
|
||||||
<div class="form-text">Blokkeert bekende AI-bots zoals <code>GPTBot</code>, <code>ChatGPT-User</code>, <code>ClaudeBot</code>, <code>PerplexityBot</code>, <code>CCBot</code>, <code>Google-Extended</code>, <code>Bytespider</code>, <code>Applebot-Extended</code>, etc.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="block_scrapers" name="block_scrapers" value="1" <?= !empty($sec['block_scrapers']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label fw-bold" for="block_scrapers">
|
|
||||||
<i class="bi bi-bug text-warning"></i> Geautomatiseerde Scrapers & Tools blokkeren (403 Forbidden)
|
|
||||||
</label>
|
|
||||||
<div class="form-text">Blokkeert automatische scraping tools zoals <code>HTTrack</code>, <code>Scrapy</code>, <code>PhantomJS</code>, <code>HeadlessChrome</code>, <code>cURL</code>, <code>Wget</code>, <code>Python-requests</code>, <code>libwww-perl</code>, etc.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="block_empty_user_agent" name="block_empty_user_agent" value="1" <?= !empty($sec['block_empty_user_agent']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label fw-bold" for="block_empty_user_agent">
|
|
||||||
<i class="bi bi-slash-circle text-secondary"></i> Verzoeken met lege User-Agent header blokkeren
|
|
||||||
</label>
|
|
||||||
<div class="form-text">Veel eenvoudige bots en aanvalscripts sturen geen User-Agent header mee.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="block_search_engines" name="block_search_engines" value="1" <?= !empty($sec['block_search_engines']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label fw-bold text-danger" for="block_search_engines">
|
|
||||||
<i class="bi bi-search"></i> Legitieme zoekmachines blokkeren (Google, Bing, DuckDuckGo, etc.)
|
|
||||||
</label>
|
|
||||||
<div class="form-text text-danger">Let op: Schakel dit alleen in als je wilt dat de hele site niet in zoekmachines (zoals Google en Bing) verschijnt.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-speedometer2"></i> Snelheidsbeperking (Rate Limiting per IP)
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="rate_limit_enabled" name="rate_limit_enabled" value="1" <?= !empty($sec['rate_limit_enabled']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label fw-bold" for="rate_limit_enabled">Rate Limiter inschakelen</label>
|
|
||||||
<div class="form-text">Voorkomt dat scrapers of bots de site overbelasten door tientallen verzoeken per seconde uit te voeren. Overschrijding geeft HTTP 429.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="rate_limit_max" class="form-label">Maximaal aantal verzoeken per IP</label>
|
|
||||||
<input type="number" class="form-control" id="rate_limit_max" name="rate_limit_max" min="10" max="1000" value="<?= (int)($sec['rate_limit_max'] ?? 60) ?>">
|
|
||||||
<small class="form-text text-muted">Aanbevolen: 60 verzoeken.</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="rate_limit_window" class="form-label">Tijdvenster (in seconden)</label>
|
|
||||||
<input type="number" class="form-control" id="rate_limit_window" name="rate_limit_window" min="10" max="3600" value="<?= (int)($sec['rate_limit_window'] ?? 60) ?>">
|
|
||||||
<small class="form-text text-muted">Aanbevolen: 60 seconden (1 minuut).</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-list-check"></i> Eigen Filters & IP-Lijsten
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="custom_blocked_agents" class="form-label fw-bold">Aangepaste User-Agent Blocklist</label>
|
|
||||||
<textarea class="form-control font-monospace" id="custom_blocked_agents" name="custom_blocked_agents" rows="3" placeholder="Typ één User-Agent patroon per regel (bijv. MyCustomBot)"><?= htmlspecialchars(implode("\n", $sec['custom_blocked_agents'] ?? [])) ?></textarea>
|
|
||||||
<div class="form-text">Verzoeken waarvan de User-Agent dit patroon bevat krijgen een 403 Forbidden.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="allowed_ips" class="form-label fw-bold text-success"><i class="bi bi-shield-check"></i> IP Whitelist (Altijd Toegang)</label>
|
|
||||||
<textarea class="form-control font-monospace" id="allowed_ips" name="allowed_ips" rows="3" placeholder="Één IP per regel (bijv. 82.169.10.20)"><?= htmlspecialchars(implode("\n", $sec['allowed_ips'] ?? [])) ?></textarea>
|
|
||||||
<div class="form-text text-success">IP's op de whitelist worden nooit geblokkeerd door bot-filters of rate limiting.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="blocked_ips" class="form-label fw-bold text-danger"><i class="bi bi-shield-x"></i> IP Blocklist (Altijd Geblokkeerd)</label>
|
|
||||||
<textarea class="form-control font-monospace" id="blocked_ips" name="blocked_ips" rows="3" placeholder="Één IP per regel (bijv. 198.51.100.4)"><?= htmlspecialchars(implode("\n", $sec['blocked_ips'] ?? [])) ?></textarea>
|
|
||||||
<div class="form-text text-danger">IP's op de blocklist krijgen altijd direct een HTTP 403 Forbidden.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg mb-4">
|
|
||||||
<i class="bi bi-check-lg"></i> Beveiligingsinstellingen opslaan
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-file-earmark-text"></i> Dynamische <code>robots.txt</code> Preview
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="bg-dark text-light p-3 rounded-bottom">
|
|
||||||
<pre class="m-0 text-light font-monospace small"><?= htmlspecialchars($robotsPreview ?? '') ?></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer text-muted small">
|
|
||||||
Deze <code>robots.txt</code> wordt automatisch geserveerd op <code>/robots.txt</code> en past zich aan je instellingen aan.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
<?php
|
|
||||||
$totals = $stats['totals'] ?? [];
|
|
||||||
$countries = $stats['countries'] ?? [];
|
|
||||||
$pages = $stats['pages'] ?? [];
|
|
||||||
$referrers = $stats['referrers'] ?? [];
|
|
||||||
$daily = $stats['daily_chart'] ?? [];
|
|
||||||
|
|
||||||
// Exclude the "UNKNOWN" bucket from the map scale
|
|
||||||
$mapCountries = $countries;
|
|
||||||
unset($mapCountries['UNKNOWN']);
|
|
||||||
$maxCountry = !empty($mapCountries) ? max($mapCountries) : 0;
|
|
||||||
$maxPage = !empty($pages) ? max($pages) : 0;
|
|
||||||
$maxDaily = 0;
|
|
||||||
foreach ($daily as $d) {
|
|
||||||
if (($d['views'] ?? 0) > $maxDaily) $maxDaily = $d['views'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$periodLabels = [7 => 'Laatste 7 dagen', 30 => 'Laatste 30 dagen', 90 => 'Laatste 90 dagen', 0 => 'Alles'];
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
|
||||||
<h2 class="mb-0"><i class="bi bi-bar-chart"></i> Statistieken</h2>
|
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
|
||||||
<div class="btn-group">
|
|
||||||
<?php foreach ($periodLabels as $p => $label): ?>
|
|
||||||
<a href="/admin/statistics?period=<?= $p ?>" class="btn btn-sm <?= $period === $p ? 'btn-primary' : 'btn-outline-secondary' ?>"><?= htmlspecialchars($label) ?></a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<a href="/admin/statistics?period=<?= $period ?>&export=csv" class="btn btn-sm btn-outline-success" title="Exporteer als CSV">
|
|
||||||
<i class="bi bi-filetype-csv"></i> CSV
|
|
||||||
</a>
|
|
||||||
<a href="/admin/statistics?period=<?= $period ?>&export=json" class="btn btn-sm btn-outline-success" title="Exporteer als JSON">
|
|
||||||
<i class="bi bi-filetype-json"></i> JSON
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KPI cards -->
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<div class="card stat-card shadow-sm h-100">
|
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-1">Paginaweergaven</h6>
|
|
||||||
<h3 class="mb-0"><?= number_format((int)($totals['views'] ?? 0), 0, ',', '.') ?></h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-eye stat-icon text-primary"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<div class="card stat-card shadow-sm h-100">
|
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-1">Unieke bezoekers</h6>
|
|
||||||
<h3 class="mb-0"><?= number_format((int)($totals['uniques'] ?? 0), 0, ',', '.') ?></h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-people stat-icon text-success"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<div class="card stat-card shadow-sm h-100">
|
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-1">Mens / Bot</h6>
|
|
||||||
<h3 class="mb-0">
|
|
||||||
<span class="text-success"><?= number_format((int)($totals['human'] ?? 0), 0, ',', '.') ?></span>
|
|
||||||
<small class="text-muted">/</small>
|
|
||||||
<span class="text-secondary"><?= number_format((int)($totals['bot'] ?? 0), 0, ',', '.') ?></span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-person-check stat-icon text-info"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<div class="card stat-card shadow-sm h-100">
|
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-1">Geblokkeerd</h6>
|
|
||||||
<h3 class="mb-0 text-danger"><?= number_format((int)($totals['blocked'] ?? 0), 0, ',', '.') ?></h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-shield-x stat-icon text-danger"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- World map -->
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<span><i class="bi bi-globe-europe-africa"></i> Bezoekers per land</span>
|
|
||||||
<?php if ($maxCountry > 0): ?>
|
|
||||||
<small class="text-muted d-flex align-items-center gap-1">
|
|
||||||
Minder
|
|
||||||
<span style="display:inline-block;width:18px;height:12px;background:#cfe2ff;border:1px solid #dee2e6;"></span>
|
|
||||||
<span style="display:inline-block;width:18px;height:12px;background:#6ea8fe;"></span>
|
|
||||||
<span style="display:inline-block;width:18px;height:12px;background:#0d6efd;"></span>
|
|
||||||
<span style="display:inline-block;width:18px;height:12px;background:#052c65;"></span>
|
|
||||||
Meer
|
|
||||||
</small>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<?php if ($worldMapSvg === ''): ?>
|
|
||||||
<div class="alert alert-warning mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle"></i> Wereldkaart niet gevonden. Genereer deze met:
|
|
||||||
<code>php cli/generate-world-map.php</code>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<style>
|
|
||||||
<?php foreach ($mapCountries as $cc => $count):
|
|
||||||
if (!preg_match('/^[A-Z]{2}$/', $cc) || $maxCountry <= 0) continue;
|
|
||||||
$ratio = $count / $maxCountry;
|
|
||||||
if ($ratio > 0.66) $fill = '#052c65';
|
|
||||||
elseif ($ratio > 0.33) $fill = '#0d6efd';
|
|
||||||
elseif ($ratio > 0.1) $fill = '#6ea8fe';
|
|
||||||
else $fill = '#cfe2ff';
|
|
||||||
?>
|
|
||||||
#<?= $cc ?> { fill: <?= $fill ?>; }
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</style>
|
|
||||||
<div class="world-map-wrapper position-relative">
|
|
||||||
<?= $worldMapSvg ?>
|
|
||||||
<div id="mapTooltip" class="position-absolute bg-dark text-white px-2 py-1 rounded small" style="display:none;pointer-events:none;z-index:10;"></div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var counts = <?= json_encode($mapCountries) ?>;
|
|
||||||
var wrapper = document.querySelector('.world-map-wrapper');
|
|
||||||
var tooltip = document.getElementById('mapTooltip');
|
|
||||||
if (!wrapper || !tooltip) return;
|
|
||||||
wrapper.querySelectorAll('path.country').forEach(function (p) {
|
|
||||||
p.addEventListener('mousemove', function (e) {
|
|
||||||
var code = p.getAttribute('id');
|
|
||||||
var name = p.getAttribute('data-name') || code;
|
|
||||||
var n = counts[code] || 0;
|
|
||||||
tooltip.textContent = name + ': ' + n + ' weergave' + (n === 1 ? '' : 'n');
|
|
||||||
tooltip.style.display = 'block';
|
|
||||||
var r = wrapper.getBoundingClientRect();
|
|
||||||
tooltip.style.left = (e.clientX - r.left + 12) + 'px';
|
|
||||||
tooltip.style.top = (e.clientY - r.top + 12) + 'px';
|
|
||||||
});
|
|
||||||
p.addEventListener('mouseleave', function () {
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php if ($geoMeta): ?>
|
|
||||||
<div class="card-footer text-muted small">
|
|
||||||
<?= htmlspecialchars($geoMeta['attribution'] ?? 'IP geolocation by DB-IP') ?> ·
|
|
||||||
Database bijgewerkt op <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-4">
|
|
||||||
<!-- Countries list -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-header"><i class="bi bi-flag"></i> Landen</div>
|
|
||||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
|
||||||
<?php if (empty($countries)): ?>
|
|
||||||
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php $totalCountryViews = array_sum($countries); ?>
|
|
||||||
<?php foreach (array_slice($countries, 0, 25, true) as $cc => $count): ?>
|
|
||||||
<?php $pct = $totalCountryViews > 0 ? round(($count / $totalCountryViews) * 100, 1) : 0; ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<div class="d-flex justify-content-between small">
|
|
||||||
<span>
|
|
||||||
<?= GeoIP::getCountryFlagEmoji($cc === 'UNKNOWN' ? null : $cc) ?>
|
|
||||||
<?= htmlspecialchars(GeoIP::getCountryName($cc === 'UNKNOWN' ? null : $cc)) ?>
|
|
||||||
</span>
|
|
||||||
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?> (<?= $pct ?>%)</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar" style="width: <?= $pct ?>%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Top pages -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-header"><i class="bi bi-file-earmark-text"></i> Meest gelezen pagina's</div>
|
|
||||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
|
||||||
<?php if (empty($pages)): ?>
|
|
||||||
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach (array_slice($pages, 0, 25, true) as $pageName => $count): ?>
|
|
||||||
<?php $pct = $maxPage > 0 ? round(($count / $maxPage) * 100, 1) : 0; ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<div class="d-flex justify-content-between small">
|
|
||||||
<span class="text-truncate" style="max-width: 70%;" title="<?= htmlspecialchars($pageName) ?>">
|
|
||||||
<?= htmlspecialchars($pageName) ?>
|
|
||||||
</span>
|
|
||||||
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar bg-success" style="width: <?= $pct ?>%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-4">
|
|
||||||
<!-- Daily chart -->
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-header"><i class="bi bi-graph-up"></i> Bezoekers per dag</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<?php if (empty($daily) || $maxDaily === 0): ?>
|
|
||||||
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php
|
|
||||||
$chartW = 800;
|
|
||||||
$chartH = 200;
|
|
||||||
$count = count($daily);
|
|
||||||
$barW = $count > 0 ? ($chartW / $count) : 10;
|
|
||||||
?>
|
|
||||||
<svg viewBox="0 0 <?= $chartW ?> <?= $chartH + 25 ?>" width="100%" height="auto">
|
|
||||||
<?php foreach (array_values($daily) as $i => $d): ?>
|
|
||||||
<?php
|
|
||||||
$v = (int)($d['views'] ?? 0);
|
|
||||||
$h = $maxDaily > 0 ? ($v / $maxDaily) * $chartH : 0;
|
|
||||||
$x = $i * $barW;
|
|
||||||
$y = $chartH - $h;
|
|
||||||
?>
|
|
||||||
<rect x="<?= round($x + 1, 2) ?>" y="<?= round($y, 2) ?>"
|
|
||||||
width="<?= round(max($barW - 2, 1), 2) ?>" height="<?= round($h, 2) ?>"
|
|
||||||
fill="#0d6efd" rx="1">
|
|
||||||
<title><?= htmlspecialchars($d['date']) ?>: <?= $v ?> weergaven, <?= (int)($d['uniques'] ?? 0) ?> unieke bezoekers</title>
|
|
||||||
</rect>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<line x1="0" y1="<?= $chartH ?>" x2="<?= $chartW ?>" y2="<?= $chartH ?>" stroke="#dee2e6" stroke-width="1"/>
|
|
||||||
<?php $firstDay = reset($daily); $lastDay = end($daily); ?>
|
|
||||||
<text x="0" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d"><?= htmlspecialchars($firstDay['date'] ?? '') ?></text>
|
|
||||||
<text x="<?= $chartW ?>" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d" text-anchor="end"><?= htmlspecialchars($lastDay['date'] ?? '') ?></text>
|
|
||||||
</svg>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Referrers -->
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-header"><i class="bi bi-link-45deg"></i> Verwijzende sites</div>
|
|
||||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
|
||||||
<?php if (empty($referrers)): ?>
|
|
||||||
<p class="text-muted mb-0">Geen verwijzingen geregistreerd.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
<?php foreach (array_slice($referrers, 0, 15, true) as $host => $count): ?>
|
|
||||||
<li class="d-flex justify-content-between border-bottom py-1 small">
|
|
||||||
<span class="text-truncate" style="max-width: 70%;"><?= htmlspecialchars($host) ?></span>
|
|
||||||
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- GeoIP & privacy settings -->
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header"><i class="bi bi-geo-alt"></i> GeoIP database & privacy</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<?php if ($geoMeta): ?>
|
|
||||||
<p class="mb-1">
|
|
||||||
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Database aanwezig</span>
|
|
||||||
<span class="text-muted small ms-2">
|
|
||||||
<?= number_format((int)($geoMeta['ipv4_records'] ?? 0), 0, ',', '.') ?> IPv4 ·
|
|
||||||
<?= number_format((int)($geoMeta['ipv6_records'] ?? 0), 0, ',', '.') ?> IPv6 ·
|
|
||||||
bijgewerkt <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<p class="mb-1"><span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Nog geen lokale database</span></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 text-md-end">
|
|
||||||
<form method="POST" action="/admin/statistics" class="d-inline">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
<input type="hidden" name="action" value="update_geoip">
|
|
||||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
|
||||||
<i class="bi bi-cloud-download"></i> GeoIP database bijwerken
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<form method="POST" action="/admin/statistics">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="analytics_enabled" name="analytics_enabled" value="1" <?= !empty($ana['enabled']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label fw-bold" for="analytics_enabled">Statistieken bijhouden</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="anonymize_ip" name="anonymize_ip" value="1" <?= !empty($ana['anonymize_ip']) ? 'checked' : '' ?>>
|
|
||||||
<label class="form-check-label fw-bold" for="anonymize_ip">IP-adressen anonimiseren</label>
|
|
||||||
<div class="form-text">Maskeert het laatste deel van het IP (82.169.10.x). Let op: de IP-blocklist wordt hierdoor minder bruikbaar.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<label for="geoip_provider" class="form-label fw-bold">GeoIP bron</label>
|
|
||||||
<select name="geoip_provider" id="geoip_provider" class="form-select">
|
|
||||||
<option value="local" <?= ($ana['geoip_provider'] ?? 'local') === 'local' ? 'selected' : '' ?>>Lokaal (DB-IP Lite)</option>
|
|
||||||
<option value="mmdb" <?= ($ana['geoip_provider'] ?? '') === 'mmdb' ? 'selected' : '' ?>>MaxMind database (.mmdb)</option>
|
|
||||||
<option value="api" <?= ($ana['geoip_provider'] ?? '') === 'api' ? 'selected' : '' ?>>Externe API</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-text">Valt automatisch terug op de lokale database.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8 mb-3">
|
|
||||||
<label for="geoip_mmdb_path" class="form-label fw-bold">Pad naar .mmdb bestand</label>
|
|
||||||
<input type="text" class="form-control font-monospace" id="geoip_mmdb_path" name="geoip_mmdb_path"
|
|
||||||
value="<?= htmlspecialchars($ana['geoip_mmdb_path'] ?? '') ?>" placeholder="/var/lib/GeoIP/GeoLite2-Country.mmdb">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-8 mb-3">
|
|
||||||
<label for="geoip_api_url" class="form-label fw-bold">API URL</label>
|
|
||||||
<input type="text" class="form-control font-monospace" id="geoip_api_url" name="geoip_api_url"
|
|
||||||
value="<?= htmlspecialchars($ana['geoip_api_url'] ?? '') ?>" placeholder="http://ip-api.com/json/{ip}?fields=countryCode">
|
|
||||||
<div class="form-text"><code>{ip}</code> wordt vervangen door het IP-adres van de bezoeker.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<label for="geoip_api_key" class="form-label fw-bold">API sleutel</label>
|
|
||||||
<input type="text" class="form-control" id="geoip_api_key" name="geoip_api_key"
|
|
||||||
value="<?= htmlspecialchars($ana['geoip_api_key'] ?? '') ?>">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<label for="retention_days" class="form-label fw-bold">Bewaartermijn (dagen)</label>
|
|
||||||
<input type="number" class="form-control" id="retention_days" name="retention_days" min="30" max="3650"
|
|
||||||
value="<?= (int)($ana['retention_days'] ?? 400) ?>">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Instellingen opslaan</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
|
||||||
<small class="text-muted">IP geolocation by DB-IP (https://db-ip.com) · CC BY 4.0</small>
|
|
||||||
<form method="POST" action="/admin/statistics" onsubmit="return confirm('Weet je zeker dat je ALLE statistieken wilt wissen?')">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
<input type="hidden" name="action" value="reset_stats">
|
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Statistieken wissen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
<?php if ($editTheme): ?>
|
|
||||||
|
|
||||||
<!-- Edit theme -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="bi bi-palette"></i> Bewerk thema: <?= htmlspecialchars($editTheme['name'] ?? $editThemeName) ?></h2>
|
|
||||||
<a href="/admin/theme" 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/theme" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<input type="hidden" name="action" value="save">
|
|
||||||
<input type="hidden" name="theme" value="<?= htmlspecialchars($editThemeName) ?>">
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="theme_name" class="form-label">Themanaam</label>
|
|
||||||
<input type="text" class="form-control" id="theme_name" name="theme_name" value="<?= htmlspecialchars($editTheme['name'] ?? $editThemeName) ?>">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Header</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="header_color" class="form-label">Achtergrond</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="color" class="form-control form-control-color" id="header_color" name="header_color" value="<?= htmlspecialchars($editTheme['header_color'] ?? '#0a369d') ?>">
|
|
||||||
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['header_color'] ?? '#0a369d') ?>" maxlength="7" data-target="header_color">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-0">
|
|
||||||
<label for="header_font_color" class="form-label">Tekst</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="color" class="form-control form-control-color" id="header_font_color" name="header_font_color" value="<?= htmlspecialchars($editTheme['header_font_color'] ?? '#ffffff') ?>">
|
|
||||||
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['header_font_color'] ?? '#ffffff') ?>" maxlength="7" data-target="header_font_color">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Navigatie</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="navigation_color" class="form-label">Achtergrond</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="color" class="form-control form-control-color" id="navigation_color" name="navigation_color" value="<?= htmlspecialchars($editTheme['navigation_color'] ?? '#2754b4') ?>">
|
|
||||||
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['navigation_color'] ?? '#2754b4') ?>" maxlength="7" data-target="navigation_color">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-0">
|
|
||||||
<label for="navigation_font_color" class="form-label">Tekst</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="color" class="form-control form-control-color" id="navigation_font_color" name="navigation_font_color" value="<?= htmlspecialchars($editTheme['navigation_font_color'] ?? '#ffffff') ?>">
|
|
||||||
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['navigation_font_color'] ?? '#ffffff') ?>" maxlength="7" data-target="navigation_font_color">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Sidebar</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="sidebar_background" class="form-label">Achtergrond</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="color" class="form-control form-control-color" id="sidebar_background" name="sidebar_background" value="<?= htmlspecialchars($editTheme['sidebar_background'] ?? '#f8f9fa') ?>">
|
|
||||||
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['sidebar_background'] ?? '#f8f9fa') ?>" maxlength="7" data-target="sidebar_background">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-0">
|
|
||||||
<label for="sidebar_border" class="form-label">Rand</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="color" class="form-control form-control-color" id="sidebar_border" name="sidebar_border" value="<?= htmlspecialchars($editTheme['sidebar_border'] ?? '#dee2e6') ?>">
|
|
||||||
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['sidebar_border'] ?? '#dee2e6') ?>" maxlength="7" data-target="sidebar_border">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mt-2">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><i class="bi bi-arrows-vertical"></i> Hoogte balken</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="header_height" class="form-label">Header hoogte (px)</label>
|
|
||||||
<input type="number" class="form-control" id="header_height" name="header_height" value="<?= htmlspecialchars($editTheme['header_height'] ?? '56') ?>" min="32" max="200">
|
|
||||||
</div>
|
|
||||||
<div class="mb-0">
|
|
||||||
<label for="nav_height" class="form-label">Navigatie hoogte (px)</label>
|
|
||||||
<input type="number" class="form-control" id="nav_height" name="nav_height" value="<?= htmlspecialchars($editTheme['nav_height'] ?? '42') ?>" min="24" max="200">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><i class="bi bi-image"></i> Header achtergrond afbeelding</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="bg_image" class="form-label">Upload afbeelding</label>
|
|
||||||
<input type="file" class="form-control" id="bg_image" name="bg_image" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml">
|
|
||||||
<small class="form-text text-muted">Toegestaan: JPG, PNG, GIF, WebP, SVG</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-0">
|
|
||||||
<label for="background_image_url" class="form-label">Of URL naar afbeelding</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" id="background_image_url" name="background_image_url" placeholder="https://..." value="<?= htmlspecialchars(str_starts_with($editTheme['background_image'] ?? '', 'http') ? $editTheme['background_image'] : '') ?>">
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($editTheme['background_image'])): ?>
|
|
||||||
<div class="mt-2 d-flex align-items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<small class="text-muted">Huidig:</small>
|
|
||||||
<img src="<?= htmlspecialchars(str_starts_with($editTheme['background_image'], 'http') ? $editTheme['background_image'] : '/themes/' . $editTheme['background_image']) ?>" style="max-height: 60px; max-width: 200px;" class="img-thumbnail mt-1 d-block">
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="btn-check" type="checkbox" id="bg_image_remove" name="bg_image_remove" value="1" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-danger btn-sm" for="bg_image_remove">
|
|
||||||
<i class="bi bi-trash3"></i> Verwijder afbeelding
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3 mt-3">
|
|
||||||
<label for="background_image_opacity" class="form-label">Doorzichtigheid (%)</label>
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<input type="range" class="form-range" style="max-width: 200px;" id="background_image_opacity" name="background_image_opacity" min="0" max="100" value="<?= htmlspecialchars($editTheme['background_image_opacity'] ?? '100') ?>" oninput="this.nextElementSibling.textContent=this.value+'%'">
|
|
||||||
<span class="badge bg-secondary"><?= htmlspecialchars($editTheme['background_image_opacity'] ?? '100') ?>%</span>
|
|
||||||
</div>
|
|
||||||
<small class="form-text text-muted">100% = volledig zichtbaar, 50% = half doorzichtig, 0% = onzichtbaar</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-lg"></i> Opslaan
|
|
||||||
</button>
|
|
||||||
<a href="/admin/theme" class="btn btn-outline-secondary">Annuleren</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php else: ?>
|
|
||||||
|
|
||||||
<!-- Theme list -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="bi bi-palette"></i> Thema's</h2>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#newThemeForm">
|
|
||||||
<i class="bi bi-plus-lg"></i> Nieuw thema
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapse mb-4" id="newThemeForm">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="/admin/theme" class="row g-3 align-items-end">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<input type="hidden" name="action" value="create">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="new_name" class="form-label">Naam nieuw thema</label>
|
|
||||||
<input type="text" class="form-control" id="new_name" name="new_name" placeholder="bijv. donker" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<button type="submit" class="btn btn-success w-100">
|
|
||||||
<i class="bi bi-plus-lg"></i> Aanmaken
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<?php foreach ($themes as $themeName => $themeData): ?>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
||||||
<h5 class="card-title mb-0"><?= htmlspecialchars($themeData['name'] ?? $themeName) ?></h5>
|
|
||||||
<?php if ($themeName === $activeTheme): ?>
|
|
||||||
<span class="badge bg-success">Actief</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Color preview swatches -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="d-flex align-items-center gap-1 mb-2">
|
|
||||||
<span class="small text-muted" style="width: 70px;">Header:</span>
|
|
||||||
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['header_color'] ?? '#0a369d') ?>; border: 1px solid #dee2e6;"></span>
|
|
||||||
<span class="small text-muted"><?= htmlspecialchars($themeData['header_color'] ?? '#0a369d') ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center gap-1 mb-2">
|
|
||||||
<span class="small text-muted" style="width: 70px;">Navigatie:</span>
|
|
||||||
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['navigation_color'] ?? '#2754b4') ?>; border: 1px solid #dee2e6;"></span>
|
|
||||||
<span class="small text-muted"><?= htmlspecialchars($themeData['navigation_color'] ?? '#2754b4') ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center gap-1">
|
|
||||||
<span class="small text-muted" style="width: 70px;">Sidebar:</span>
|
|
||||||
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['sidebar_background'] ?? '#f8f9fa') ?>; border: 1px solid #dee2e6;"></span>
|
|
||||||
<span class="small text-muted"><?= htmlspecialchars($themeData['sidebar_background'] ?? '#f8f9fa') ?></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Heights and background image -->
|
|
||||||
<div class="mb-3 small">
|
|
||||||
<div class="text-muted mb-1">
|
|
||||||
<i class="bi bi-arrows-vertical"></i> Header: <?= htmlspecialchars($themeData['header_height'] ?? '56') ?>px · Navigatie: <?= htmlspecialchars($themeData['nav_height'] ?? '42') ?>px
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($themeData['background_image'])): ?>
|
|
||||||
<div class="text-muted">
|
|
||||||
<i class="bi bi-image"></i> Achtergrond: <?= htmlspecialchars($themeData['background_image_opacity'] ?? '100') ?>% zichtbaar
|
|
||||||
<?php if (!str_starts_with($themeData['background_image'], 'http')): ?>
|
|
||||||
<img src="/themes/<?= htmlspecialchars($themeData['background_image']) ?>" style="max-height: 30px; max-width: 80px;" class="img-thumbnail ms-1 align-middle">
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<a href="/admin/theme?edit=<?= urlencode($themeName) ?>" class="btn btn-outline-primary btn-sm">
|
|
||||||
<i class="bi bi-pencil"></i> Bewerken
|
|
||||||
</a>
|
|
||||||
<?php if ($themeName !== $activeTheme): ?>
|
|
||||||
<form method="POST" action="/admin/theme">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<input type="hidden" name="action" value="activate">
|
|
||||||
<input type="hidden" name="theme" value="<?= htmlspecialchars($themeName) ?>">
|
|
||||||
<button type="submit" class="btn btn-outline-success btn-sm">
|
|
||||||
<i class="bi bi-check-circle"></i> Activeren
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($themeName !== 'default'): ?>
|
|
||||||
<form method="POST" action="/admin/theme">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
<input type="hidden" name="theme" value="<?= htmlspecialchars($themeName) ?>">
|
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Weet je zeker dat je thema '<?= htmlspecialchars($themeData['name'] ?? $themeName) ?>' wilt verwijderen?')">
|
|
||||||
<i class="bi bi-trash"></i> Verwijderen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.form-control-color-value').forEach(function(input) {
|
|
||||||
input.addEventListener('input', function() {
|
|
||||||
var target = document.getElementById(this.dataset.target);
|
|
||||||
if (target && /^#[0-9a-fA-F]{6}$/.test(this.value)) {
|
|
||||||
target.value = this.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.form-control-color').forEach(function(input) {
|
|
||||||
input.addEventListener('input', function() {
|
|
||||||
var target = document.querySelector('[data-target="' + this.id + '"]');
|
|
||||||
if (target) target.value = this.value;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<h2 class="mb-4"><i class="bi bi-cloud-arrow-down"></i> Systeem Update</h2>
|
|
||||||
|
|
||||||
<?php if (isset($isGitWritable) && $isGitWritable === false): ?>
|
|
||||||
<div class="alert alert-warning mb-4">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
|
||||||
<strong>Schrijfrechten vereist voor Git:</strong> De PHP-webserver heeft geen schrijfrechten op de <code>.git/objects</code> map op de server.
|
|
||||||
<br><br>
|
|
||||||
Voer op de live server eenmalig uit in de terminal (als <code>root</code>):
|
|
||||||
<pre class="bg-dark text-white p-2 rounded mt-2 mb-0 font-monospace">chown -R www-data:www-data /var/www/CodePress</pre>
|
|
||||||
<small class="text-muted mt-1 d-block">(Vervang <code>www-data</code> door jouw webserver gebruiker, bijvoorbeeld <code>www</code> of <code>nginx</code>, als dat anders is).</small>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (!empty($updateOutput)): ?>
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header bg-dark text-white">
|
|
||||||
<i class="bi bi-terminal"></i> Update Resultaten
|
|
||||||
</div>
|
|
||||||
<div class="card-body bg-dark text-light p-3">
|
|
||||||
<pre class="m-0 text-light" style="font-family: monospace; font-size: 0.9rem;"><?= htmlspecialchars($updateOutput) ?></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-info-circle"></i> Systeeminformatie
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<strong>Huidige CMS Versie:</strong>
|
|
||||||
<span class="badge bg-primary ms-2"><?= htmlspecialchars($cmsVersion ?? '1.7.1') ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<strong>Configuratiestatus:</strong>
|
|
||||||
<span class="badge bg-success ms-2"><i class="bi bi-check-circle"></i> Lokaal afgeschermd (Git-safe)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted small mb-0">
|
|
||||||
Lokale configuratiebestanden (zoals <code>config.json</code> en <code>admin.json</code>) zijn uitgesloten van Git.
|
|
||||||
Hierdoor blijven je instellingen, wachtwoorden en content veilig behouden tijdens het bijwerken.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-arrow-repeat"></i> CodePress CMS Bijwerken
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Haal automatisch de nieuwste CMS updates op via het Git repository.</p>
|
|
||||||
|
|
||||||
<?php if (!empty($gitBranch)): ?>
|
|
||||||
<div class="mb-3">
|
|
||||||
<small class="text-muted">Git branch: <code><?= htmlspecialchars($gitBranch) ?></code></small>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<form method="POST" action="/admin/update">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg" onclick="return confirm('Weet je zeker dat je het systeem wilt bijwerken naar de nieuwste versie?')">
|
|
||||||
<i class="bi bi-cloud-download"></i> Systeem nu bijwerken
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<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 mb-4">
|
|
||||||
<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: 200px;">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>
|
|
||||||
<?php if ($u['username'] === $user['username']): ?>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#changeOwnPasswordModal" title="Eigen wachtwoord wijzigen">
|
|
||||||
<i class="bi bi-key"></i> Wachtwoord
|
|
||||||
</button>
|
|
||||||
<?php else: ?>
|
|
||||||
<!-- Change password (admin for other users) -->
|
|
||||||
<form method="POST" action="/admin/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>
|
|
||||||
<form method="POST" action="/admin/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>
|
|
||||||
|
|
||||||
<!-- Change own password modal -->
|
|
||||||
<div class="modal fade" id="changeOwnPasswordModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form method="POST" action="/admin/users">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
|
||||||
<input type="hidden" name="action" value="change_own_password">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-key"></i> Eigen wachtwoord wijzigen</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="current_password" class="form-label">Huidig wachtwoord</label>
|
|
||||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_password" class="form-label">Nieuw wachtwoord</label>
|
|
||||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
|
||||||
<small class="form-text text-muted">Minimaal 8 tekens.</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="confirm_password" class="form-label">Bevestig nieuw wachtwoord</label>
|
|
||||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="8">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
|
|
||||||
<button type="submit" class="btn btn-warning"><i class="bi bi-check-lg"></i> Wachtwoord wijzigen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</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/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>
|
|
||||||
Vendored
Vendored
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-display: block;
|
font-display: block;
|
||||||
font-family: "bootstrap-icons";
|
font-family: "bootstrap-icons";
|
||||||
src: url("./fonts/bootstrap-icons.woff2?1bb88866b4085542c8ed5fb61b9393dd") format("woff2"),
|
src: url("../fonts/bootstrap-icons.woff2?1bb88866b4085542c8ed5fb61b9393dd") format("woff2"),
|
||||||
url("./fonts/bootstrap-icons.woff?1bb88866b4085542c8ed5fb61b9393dd") format("woff");
|
url("../fonts/bootstrap-icons.woff?1bb88866b4085542c8ed5fb61b9393dd") format("woff");
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi::before,
|
.bi::before,
|
||||||
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
|||||||
|
/* Admin theme styles */
|
||||||
|
|
||||||
|
/* Code block styling (for guide pages) */
|
||||||
|
pre {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #e8e8e8;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #d63384;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
Vendored
+10
-3
@@ -5,6 +5,7 @@
|
|||||||
if (!textarea || typeof CodeMirror === 'undefined') return;
|
if (!textarea || typeof CodeMirror === 'undefined') return;
|
||||||
|
|
||||||
var ext = textarea.dataset.ext || 'md';
|
var ext = textarea.dataset.ext || 'md';
|
||||||
|
var form = document.getElementById('editor-form') || textarea.closest('form');
|
||||||
|
|
||||||
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php' };
|
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php' };
|
||||||
|
|
||||||
@@ -20,11 +21,14 @@
|
|||||||
indentWithTabs: true,
|
indentWithTabs: true,
|
||||||
viewportMargin: Infinity,
|
viewportMargin: Infinity,
|
||||||
extraKeys: {
|
extraKeys: {
|
||||||
'Ctrl-S': function () { document.getElementById('editor-form').submit(); },
|
'Ctrl-S': function () { if (form) form.submit(); },
|
||||||
'Cmd-S': function () { document.getElementById('editor-form').submit(); }
|
'Cmd-S': function () { if (form) form.submit(); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose editor globally so other scripts can access it
|
||||||
|
window.codeMirrorEditor = editor;
|
||||||
|
|
||||||
var commands = {
|
var commands = {
|
||||||
md: [
|
md: [
|
||||||
{ cmd: 'bold', icon: 'bi-type-bold', title: 'Vet' },
|
{ cmd: 'bold', icon: 'bi-type-bold', title: 'Vet' },
|
||||||
@@ -227,9 +231,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('editor-form').addEventListener('submit', function () {
|
var form = document.getElementById('editor-form') || textarea.closest('form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
editor.save();
|
editor.save();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts: Ctrl/Cmd+S saves, Ctrl/Cmd+N creates a new page
|
// Keyboard shortcuts: Ctrl/Cmd+S saves, Ctrl/Cmd+N creates a new page
|
||||||
function handleShortcut(e) {
|
function handleShortcut(e) {
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"title": "CodePress Admin Default",
|
||||||
|
"type": "admin",
|
||||||
|
"default_layout": "admin",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ admin_lang|default('nl') }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}{{ ta.admin_title|default('CodePress Admin') }}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
|
||||||
|
<style>
|
||||||
|
body { background-color: #f5f6fa; min-height: 100vh; }
|
||||||
|
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; display: flex; flex-direction: column; }
|
||||||
|
.admin-sidebar .nav.flex-column:first-of-type { flex: 1; overflow-y: auto; }
|
||||||
|
.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-sidebar .nav-section { color: rgba(255,255,255,0.4); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; padding: 1rem 1.25rem 0.3rem 1.25rem; }
|
||||||
|
.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); }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
|
||||||
|
.admin-main { margin-left: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% if needs_editor %}
|
||||||
|
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||||
|
{% endif %}
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="admin-sidebar d-flex flex-column">
|
||||||
|
<div class="admin-brand">
|
||||||
|
<i class="bi bi-gear-fill"></i> {{ ta.admin_title|default('CodePress Admin') }}
|
||||||
|
</div>
|
||||||
|
<ul class="nav flex-column mt-2">
|
||||||
|
{# Algemene sectie: Dashboard (altijd zichtbaar) + plugin items met section=general #}
|
||||||
|
{% set general_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'general' and item.route != 'dashboard') %}
|
||||||
|
<li class="nav-section">{{ ta.section_general|default('Algemeen') }}</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'dashboard' or route == '' ? 'active' : '' }}" href="/admin/dashboard">
|
||||||
|
<i class="bi bi-speedometer2"></i> {{ ta.dashboard|default('Dashboard') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for item in general_plugins %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
|
||||||
|
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ item.label|default(item.route) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if has_permission('content') %}
|
||||||
|
<li class="nav-section">{{ ta.section_content|default('Content') }}</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route starts with 'content' ? 'active' : '' }}" href="/admin/content">
|
||||||
|
<i class="bi bi-file-earmark-text"></i> {{ ta.content|default('Content') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_permission('config') or has_permission('theme') or has_permission('security') %}
|
||||||
|
<li class="nav-section">{{ ta.section_settings|default('Instellingen') }}</li>
|
||||||
|
{% if has_permission('config') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'config' ? 'active' : '' }}" href="/admin/config">
|
||||||
|
<i class="bi bi-sliders"></i> {{ ta.configuration|default('Configuratie') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('theme') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'theme' ? 'active' : '' }}" href="/admin/theme">
|
||||||
|
<i class="bi bi-palette"></i> {{ ta.theme|default('Thema') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('security') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'security' ? 'active' : '' }}" href="/admin/security">
|
||||||
|
<i class="bi bi-shield-check"></i> {{ ta.security|default('Beveiliging') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Systeem sectie: core admin items + plugin items met section=system #}
|
||||||
|
{% set system_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'system') %}
|
||||||
|
{% if has_permission('plugins') or has_permission('users') or has_permission('update') or system_plugins is not empty %}
|
||||||
|
<li class="nav-section">{{ ta.section_system|default('Systeem') }}</li>
|
||||||
|
{% if has_permission('plugins') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'plugins' ? 'active' : '' }}" href="/admin/plugins">
|
||||||
|
<i class="bi bi-plug"></i> {{ ta.plugins|default('Plugins') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('users') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'users' ? 'active' : '' }}" href="/admin/users">
|
||||||
|
<i class="bi bi-people"></i> {{ ta.users|default('Gebruikers') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('update') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'update' ? 'active' : '' }}" href="/admin/update">
|
||||||
|
<i class="bi bi-cloud-arrow-down"></i> {{ ta.update|default('Update') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for item in system_plugins %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
|
||||||
|
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ item.label|default(item.route) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_permission('guide') %}
|
||||||
|
<li class="nav-section">{{ ta.section_help|default('Help') }}</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ route == 'guide' ? 'active' : '' }}" href="/admin/guide">
|
||||||
|
<i class="bi bi-book"></i> {{ ta.guide|default('Handleiding') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="nav flex-column mt-auto mb-5">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/" target="_blank">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i> {{ ta.view_website|default('Website bekijken') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-warning" href="/admin/logout">
|
||||||
|
<i class="bi bi-box-arrow-left"></i> {{ ta.logout|default('Uitloggen') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="admin-user">
|
||||||
|
<i class="bi bi-person-circle"></i> {{ user.username|default('') }}
|
||||||
|
<br><small>{{ role_label(user_role)|default('') }}</small>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
{% if message is defined and message %}
|
||||||
|
<div class="alert alert-{{ message_type|default('info') }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% if needs_editor %}
|
||||||
|
<script src="/admin/assets/codemirror/codemirror.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/mode/markdown.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/mode/xml.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/mode/css.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/mode/javascript.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/mode/htmlmixed.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/mode/php.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/mode/clike.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/addon/edit/closebrackets.min.js"></script>
|
||||||
|
<script src="/admin/assets/codemirror/addon/selection/active-line.min.js"></script>
|
||||||
|
<script src="/admin/assets/js/editor-toolbar.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="nl">
|
<html lang="{{ admin_lang|default('nl') }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CodePress Admin - Login</title>
|
<title>{{ ta.login_title|default('CodePress Admin - Login') }}</title>
|
||||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
<link rel="stylesheet" href="/admin/assets/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="/assets/css/bootstrap-icons.css">
|
<link rel="stylesheet" href="/admin/assets/css/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/style.css">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #f5f6fa; }
|
body { background-color: #f5f6fa; }
|
||||||
.login-card { max-width: 400px; margin: 10vh auto; }
|
.login-card { max-width: 400px; margin: 10vh auto; }
|
||||||
@@ -16,27 +17,27 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<h4><i class="bi bi-shield-lock"></i> CodePress Admin</h4>
|
<h4><i class="bi bi-shield-lock"></i> {{ ta.admin_title|default('CodePress Admin') }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border-0 shadow-sm" style="border-radius: 0 0 0.5rem 0.5rem;">
|
<div class="card border-0 shadow-sm" style="border-radius: 0 0 0.5rem 0.5rem;">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<?php if (!empty($error)): ?>
|
{% if error %}
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
<?= htmlspecialchars($error) ?>
|
{{ error }}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
{% endif %}
|
||||||
<form method="POST" action="/admin/login">
|
<form method="POST" action="/admin/login">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Gebruikersnaam</label>
|
<label for="username" class="form-label">{{ ta.username_label|default('Gebruikersnaam') }}</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">Wachtwoord</label>
|
<label for="password" class="form-label">{{ ta.password_label|default('Wachtwoord') }}</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
@@ -44,17 +45,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-box-arrow-in-right"></i> Inloggen
|
<i class="bi bi-box-arrow-in-right"></i> {{ ta.login_btn|default('Inloggen') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-muted mt-3 small">
|
<p class="text-center text-muted mt-3 small">
|
||||||
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left"></i> Terug naar website</a>
|
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left"></i> {{ ta.back_to_website|default('Terug naar website') }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.configuration|default('Configuratie') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-sliders"></i> {{ ta.configuration|default('Configuratie') }}</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="/admin/config">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">{{ ta.section_settings|default('Instellingen') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site_title" class="form-label">{{ ta.site_title|default('Site titel') }}</label>
|
||||||
|
<input type="text" class="form-control" id="site_title" name="site_title" value="{{ config.site_title|default('CodePress') }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="admin_language" class="form-label">{{ ta.admin_language|default('Admin taal') }}</label>
|
||||||
|
<select class="form-select" id="admin_language" name="admin_language">
|
||||||
|
<option value="nl" {{ config.admin_language|default('nl') == 'nl' ? 'selected' : '' }}>Nederlands</option>
|
||||||
|
<option value="en" {{ config.admin_language == 'en' ? 'selected' : '' }}>English</option>
|
||||||
|
<option value="de" {{ config.admin_language == 'de' ? 'selected' : '' }}>Deutsch</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">{{ ta.admin_language_help|default('Taal van het admin-paneel (menu, knoppen, labels).') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="content_language" class="form-label">{{ ta.content_language|default('Content taal') }}</label>
|
||||||
|
<select class="form-select" id="content_language" name="content_language">
|
||||||
|
<option value="nl" {{ config.language.default|default('nl') == 'nl' ? 'selected' : '' }}>Nederlands</option>
|
||||||
|
<option value="en" {{ config.language.default == 'en' ? 'selected' : '' }}>English</option>
|
||||||
|
<option value="de" {{ config.language.default == 'de' ? 'selected' : '' }}>Deutsch</option>
|
||||||
|
<option value="fr" {{ config.language.default == 'fr' ? 'selected' : '' }}>Français</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">{{ ta.content_language_help|default('Standaardtaal van de website-content (fallback als er geen taal in de URL staat).') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">{{ ta.homepage|default('Homepage') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">{{ ta.homepage_help|default('Bepaal welke pagina getoond wordt op de homepage.') }}</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ ta.homepage_mode|default('Homepage-modus') }}</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="default_page" id="dp_auto" value="auto" {{ current_default_page == 'auto' ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="dp_auto">{{ ta.homepage_auto|default('Automatisch — eerste beschikbare pagina') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="default_page" id="dp_newest" value="newest" {{ current_default_page == 'newest' ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="dp_newest">{{ ta.homepage_newest|default('Meest recent aangepaste pagina') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="default_page" id="dp_specific" value="specific" {{ current_default_page not in ['auto', 'newest'] ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="dp_specific">{{ ta.homepage_specific|default('Specifieke pagina') }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="specific_page_wrapper" style="display: none;">
|
||||||
|
<label for="default_page_specific" class="form-label">{{ ta.homepage_select|default('Selecteer pagina') }}</label>
|
||||||
|
<select class="form-select" id="default_page_specific" name="default_page_specific">
|
||||||
|
{% for pageKey, pageLabel in content_pages %}
|
||||||
|
<option value="{{ pageKey }}" {{ current_default_page == pageKey ? 'selected' : '' }}>{{ pageLabel }}</option>
|
||||||
|
{% else %}
|
||||||
|
<option value="" disabled>{{ ta.no_pages_found|default('Geen pagina\'s gevonden in content/') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var radios = document.querySelectorAll('input[name="default_page"]');
|
||||||
|
var wrapper = document.getElementById('specific_page_wrapper');
|
||||||
|
var specificSelect = document.getElementById('default_page_specific');
|
||||||
|
|
||||||
|
function toggleWrapper() {
|
||||||
|
var checked = document.querySelector('input[name="default_page"]:checked');
|
||||||
|
if (checked && checked.value === 'specific') {
|
||||||
|
wrapper.style.display = '';
|
||||||
|
} else {
|
||||||
|
wrapper.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
radios.forEach(function (r) {
|
||||||
|
r.addEventListener('change', toggleWrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the specific select changes but the radio isn't on "specific", switch to it
|
||||||
|
if (specificSelect) {
|
||||||
|
specificSelect.addEventListener('change', function () {
|
||||||
|
var specRadio = document.getElementById('dp_specific');
|
||||||
|
if (specRadio && !specRadio.checked) {
|
||||||
|
specRadio.checked = true;
|
||||||
|
toggleWrapper();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleWrapper();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.backup_restore|default('Backup & Restore') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-archive"></i> {{ ta.backup_restore|default('Backup & Restore') }}</h2>
|
||||||
|
<a href="/admin/content" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> {{ ta.back_to_content|default('Terug naar content') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-{{ message_type }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><i class="bi bi-file-zip"></i> {{ ta.zip_backup|default('ZIP Backup') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">{{ ta.zip_backup_help|default('Download de volledige content-map als ZIP bestand.') }}</p>
|
||||||
|
<form method="POST" action="/admin/content-backup">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="download_zip">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-download"></i> {{ ta.download_zip|default('Download ZIP') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><i class="bi bi-upload"></i> {{ ta.restore_from_zip|default('Herstellen uit ZIP') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">{{ ta.restore_help|default('Upload een eerder gedownloade ZIP bestand om content te herstellen. De huidige content wordt eerst geback-upt.') }}</p>
|
||||||
|
<form method="POST" action="/admin/content-restore" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="zipfile" class="form-label">{{ ta.select_zip|default('ZIP bestand selecteren') }}</label>
|
||||||
|
<input type="file" class="form-control" id="zipfile" name="zipfile" accept=".zip" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning" onclick="return confirm('{{ ta.confirm_restore|default('Weet je zeker dat je de content wilt herstellen? Huidige content wordt geback-upt.') }}')">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i> {{ ta.restore|default('Herstellen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-git"></i> {{ ta.git_versioning|default('Git Versioning') }}</span>
|
||||||
|
{% if git_available and not has_git_repo %}
|
||||||
|
<form method="POST" action="/admin/content-git-init" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-check2-circle"></i> {{ ta.git_init|default('Git init') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if not git_available %}
|
||||||
|
<div class="alert alert-secondary mb-0">
|
||||||
|
<i class="bi bi-info-circle"></i> {{ ta.git_not_available|default('Git is niet beschikbaar op deze server. Gebruik de ZIP backup/restore opties hierboven.') }}
|
||||||
|
</div>
|
||||||
|
{% elseif not has_git_repo %}
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<i class="bi bi-info-circle"></i> {{ ta.git_not_initialized|default('Geen git repository in content/. Klik op "Git init" om versiebeheer te starten.') }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" action="/admin/content-git-commit" class="mb-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" name="commit_message" placeholder="{{ ta.commit_message_placeholder|default('Commit bericht...') }}" maxlength="200">
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.commit|default('Commit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if git_commits is empty %}
|
||||||
|
<p class="text-muted">{{ ta.no_commits|default('Nog geen commits. Maak een eerste commit aan.') }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ ta.commit_short|default('Commit') }}</th>
|
||||||
|
<th>{{ ta.date|default('Datum') }}</th>
|
||||||
|
<th>{{ ta.message|default('Bericht') }}</th>
|
||||||
|
<th>{{ ta.actions|default('Acties') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for commit in git_commits %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ commit.short }}</code></td>
|
||||||
|
<td class="text-muted small">{{ commit.date }}</td>
|
||||||
|
<td>{{ commit.message }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/admin/content-git-restore" class="d-inline" onsubmit="return confirm('{{ ta.confirm_git_restore|default('Weet je zeker dat je de content wilt herstellen naar deze commit?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="commit" value="{{ commit.hash }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning" title="{{ ta.restore_this|default('Herstellen naar deze commit') }}">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.rename_folder|default('Map hernoemen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ ta.rename_folder|default('Map hernoemen') }}</h2>
|
||||||
|
|
||||||
|
<form method="post" class="card shadow-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newname" class="form-label">{{ ta.new_name_for|default('Nieuwe naam voor') }} {{ currentName }}</label>
|
||||||
|
<input type="text" class="form-control" id="newname" name="newname" value="{{ currentName }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.rename|default('Hernoemen') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ fileName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/content-edit?file={{ file|url_encode }}" id="editor-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="filename" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="filename" name="filename" value="{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" required>
|
||||||
|
<span class="input-group-text">.{{ fileExt }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
|
||||||
|
</div>
|
||||||
|
{% if isEditable %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="layout" class="form-label">{{ ta.template_layout|default('Sjabloon / Layout') }} <small class="text-muted">({{ activeThemeName|default('default') }} theme)</small></label>
|
||||||
|
<select class="form-select" id="layout" name="layout">
|
||||||
|
{% for key, layoutFile in themeLayouts %}
|
||||||
|
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% if availablePlugins is not empty %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label d-block">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% for plugin in availablePlugins %}
|
||||||
|
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
|
||||||
|
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if isEditable %}
|
||||||
|
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
{% if isEditable %}
|
||||||
|
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
||||||
|
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/admin/content?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var backBtn = document.getElementById('back-btn');
|
||||||
|
var filenameInput = document.getElementById('filename');
|
||||||
|
var layoutSelect = document.getElementById('layout');
|
||||||
|
if (!backBtn) return;
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
function markChanged() {
|
||||||
|
if (changed) return;
|
||||||
|
changed = true;
|
||||||
|
backBtn.innerHTML = '<i class="bi bi-x-circle"></i> Annuleren';
|
||||||
|
backBtn.classList.remove('btn-outline-secondary');
|
||||||
|
backBtn.classList.add('btn-outline-danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filenameInput) {
|
||||||
|
filenameInput.addEventListener('input', markChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the layout value in the editor's frontmatter when the select changes
|
||||||
|
if (layoutSelect) {
|
||||||
|
layoutSelect.addEventListener('change', function () {
|
||||||
|
var newLayout = this.value;
|
||||||
|
var editor = document.getElementById('editor-textarea');
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
var cm = window.codeMirrorEditor;
|
||||||
|
var content = cm ? cm.getValue() : editor.value;
|
||||||
|
|
||||||
|
// Check if frontmatter exists
|
||||||
|
var fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (fmMatch) {
|
||||||
|
// Update existing layout line in frontmatter
|
||||||
|
var frontmatter = fmMatch[1];
|
||||||
|
if (/^layout:\s*.+$/m.test(frontmatter)) {
|
||||||
|
frontmatter = frontmatter.replace(/^layout:\s*.+$/m, 'layout: ' + newLayout);
|
||||||
|
} else {
|
||||||
|
frontmatter = 'layout: ' + newLayout + '\n' + frontmatter;
|
||||||
|
}
|
||||||
|
content = content.replace(/^---\n([\s\S]*?)\n---/, '---\n' + frontmatter + '\n---');
|
||||||
|
} else {
|
||||||
|
// No frontmatter yet — add one
|
||||||
|
content = '---\nlayout: ' + newLayout + '\n---\n\n' + content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cm) {
|
||||||
|
cm.setValue(content);
|
||||||
|
} else {
|
||||||
|
editor.value = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
markChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__onContentChange = markChanged;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{% if isDir %}{{ ta.folder|default('Map') }}{% else %}{{ ta.file|default('Bestand') }}{% endif %} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-arrows-move"></i> {% if isDir %}{{ ta.folder|default('Map') }}{% else %}{{ ta.file|default('Bestand') }}{% endif %} {{ ta.move_suffix|default('verplaatsen') }}</h2>
|
||||||
|
|
||||||
|
<form method="post" class="card shadow-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong> {{ ta.move_to|default('naar:') }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }}</label>
|
||||||
|
<select class="form-select" id="destination" name="destination" required>
|
||||||
|
{% for directory in directories %}
|
||||||
|
<option value="{{ directory }}">{{ directory }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/content?dir={{ itemDir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.new_page|default('Nieuwe pagina') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-plus-lg"></i> {{ ta.new_page|default('Nieuwe pagina') }}</h2>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/content-new?dir={{ dir|url_encode }}" id="editor-form" data-new-page>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="filename" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="filename" name="filename" required autofocus>
|
||||||
|
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="extension" class="form-label">{{ ta.file_type|default('Bestandstype') }}</label>
|
||||||
|
<select class="form-select" id="extension" name="extension">
|
||||||
|
{% for ext, label in availableExtensions %}
|
||||||
|
<option value="{{ ext }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="layout" class="form-label">{{ ta.template_layout|default('Sjabloon / Layout') }} <small class="text-muted">({{ activeThemeName|default('default') }} theme)</small></label>
|
||||||
|
<select class="form-select" id="layout" name="layout">
|
||||||
|
{% for key, layoutFile in themeLayouts %}
|
||||||
|
<option value="{{ key }}" {{ key == themeDefaultLayout ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.content|default('Content') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-file-earmark-text"></i> Content</h2>
|
<h2><i class="bi bi-file-earmark-text"></i> {{ ta.content|default('Content') }}</h2>
|
||||||
<div>
|
<div>
|
||||||
<button type="button" class="btn btn-outline-success btn-sm me-1" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
<button type="button" class="btn btn-outline-success btn-sm me-1" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
||||||
<i class="bi bi-cloud-upload"></i> Upload
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" data-bs-toggle="modal" data-bs-target="#createDirModal">
|
<button type="button" class="btn btn-outline-secondary btn-sm me-1" data-bs-toggle="modal" data-bs-target="#createDirModal">
|
||||||
<i class="bi bi-folder-plus"></i> Nieuwe map
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/content-new?dir=<?= urlencode($subdir) ?>" class="btn btn-primary btn-sm">
|
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm me-1">
|
||||||
<i class="bi bi-plus-lg"></i> Nieuw bestand
|
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/content-new?dir={{ subdir|url_encode }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,52 +24,46 @@
|
|||||||
<div class="collapse mb-4" id="uploadForm">
|
<div class="collapse mb-4" id="uploadForm">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="/admin/content?dir=<?= urlencode($subdir) ?>" enctype="multipart/form-data">
|
<form method="POST" action="/admin/content?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="file" class="form-label">Bestanden selecteren</label>
|
<label for="file" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
|
||||||
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav">
|
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav">
|
||||||
<small class="form-text text-muted">Toegestaan: JPG, PNG, GIF, WebP, SVG, PDF, ZIP, MP4, WebM, MP3, WAV</small>
|
<small class="form-text text-muted">{{ ta.allowed_types|default('Toegestaan: JPG, PNG, GIF, WebP, SVG, PDF, ZIP, MP4, WebM, MP3, WAV') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-success">
|
<button type="submit" class="btn btn-success">
|
||||||
<i class="bi bi-cloud-upload"></i> Uploaden naar deze map
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload_to_folder|default('Uploaden naar deze map') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($subdir)): ?>
|
{% if subdir %}
|
||||||
<?php
|
{% set parentDir = subdir|split('/')|slice(0, -1)|join('/') %}
|
||||||
$parentDir = dirname($subdir);
|
|
||||||
$parentLink = $parentDir === '.' ? '' : $parentDir;
|
|
||||||
?>
|
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-house"></i></a></li>
|
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-house"></i></a></li>
|
||||||
<?php
|
{% set crumbPath = '' %}
|
||||||
$crumbPath = '';
|
{% for crumb in subdir|split('/') %}
|
||||||
foreach (explode('/', $subdir) as $i => $crumb):
|
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ crumb : crumb %}
|
||||||
$crumbPath .= ($crumbPath ? '/' : '') . $crumb;
|
<li class="breadcrumb-item {{ crumbPath == subdir ? 'active' : '' }}">
|
||||||
?>
|
{% if crumbPath == subdir %}
|
||||||
<li class="breadcrumb-item <?= $crumbPath === $subdir ? 'active' : '' ?>">
|
{{ crumb }}
|
||||||
<?php if ($crumbPath === $subdir): ?>
|
{% else %}
|
||||||
<?= htmlspecialchars($crumb) ?>
|
<a href="/admin/content?dir={{ crumbPath|url_encode }}">{{ crumb }}</a>
|
||||||
<?php else: ?>
|
{% endif %}
|
||||||
<a href="/admin/content?dir=<?= urlencode($crumbPath) ?>"><?= htmlspecialchars($crumb) ?></a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
<?php endif; ?>
|
{% endif %}
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header bg-white py-2">
|
<div class="card-header bg-white py-2">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||||
<input type="search" id="contentFilter" class="form-control border-start-0"
|
<input type="search" id="contentFilter" class="form-control border-start-0" placeholder="{{ ta.filter_placeholder|default('Filter op bestands- of mapnaam…') }}" autocomplete="off" aria-label="{{ ta.filter_content|default('Filter content') }}">
|
||||||
placeholder="Filter op bestands- of mapnaam…" autocomplete="off" aria-label="Filter content">
|
|
||||||
<span class="input-group-text bg-white text-muted" id="contentFilterCount"></span>
|
<span class="input-group-text bg-white text-muted" id="contentFilterCount"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,81 +71,74 @@
|
|||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Naam</th>
|
<th>{{ ta.col_name|default('Naam') }}</th>
|
||||||
<th>Type</th>
|
<th>{{ ta.col_type|default('Type') }}</th>
|
||||||
<th>Grootte</th>
|
<th>{{ ta.col_size|default('Grootte') }}</th>
|
||||||
<th>Gewijzigd</th>
|
<th>{{ ta.col_modified|default('Gewijzigd') }}</th>
|
||||||
<th style="width: 200px;">Acties</th>
|
<th style="width: 200px;">{{ ta.col_actions|default('Acties') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($items)): ?>
|
{% if items is empty %}
|
||||||
<tr><td colspan="5" class="text-muted text-center py-4">Geen bestanden gevonden.</td></tr>
|
<tr><td colspan="5" class="text-muted text-center py-4">{{ ta.no_files_found|default('Geen bestanden gevonden.') }}</td></tr>
|
||||||
<?php else: ?>
|
{% else %}
|
||||||
<?php foreach ($items as $item): ?>
|
{% for item in items %}
|
||||||
<tr data-name="<?= htmlspecialchars(mb_strtolower($item['name'])) ?>">
|
<tr data-name="{{ item.name|lower }}">
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item['is_dir']): ?>
|
{% if item.is_dir %}
|
||||||
<a href="/admin/content?dir=<?= urlencode($item['path']) ?>">
|
<a href="/admin/content?dir={{ item.path|url_encode }}">
|
||||||
<i class="bi bi-folder-fill text-warning"></i> <?= htmlspecialchars($item['name']) ?>
|
<i class="bi bi-folder-fill text-warning"></i> {{ item.name }}
|
||||||
</a>
|
</a>
|
||||||
<?php else: ?>
|
{% else %}
|
||||||
<a href="/admin/content-edit?file=<?= urlencode($item['path']) ?>">
|
<a href="/admin/content-edit?file={{ item.path|url_encode }}">
|
||||||
<?php
|
{% set icon = item.extension == 'md' ? 'bi-file-text text-primary' : (item.extension == 'php' ? 'bi-file-code text-success' : (item.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted')) %}
|
||||||
$icon = match($item['extension']) {
|
<i class="bi {{ icon }}"></i> {{ item.name }}
|
||||||
'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>
|
</a>
|
||||||
<?php endif; ?>
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item['is_dir']): ?>
|
{% if item.is_dir %}
|
||||||
<span class="badge bg-warning text-dark">Map</span>
|
<span class="badge bg-warning text-dark">{{ ta.folder_badge|default('Map') }}</span>
|
||||||
<?php else: ?>
|
{% else %}
|
||||||
<span class="badge bg-secondary"><?= strtoupper($item['extension']) ?></span>
|
<span class="badge bg-secondary">{{ item.extension|upper }}</span>
|
||||||
<?php endif; ?>
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted"><?= $item['size'] ?></td>
|
<td class="text-muted">{{ item.size }}</td>
|
||||||
<td class="text-muted"><?= $item['modified'] ?></td>
|
<td class="text-muted">{{ item.modified }}</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item['is_dir']): ?>
|
{% if item.is_dir %}
|
||||||
<a href="/admin/content-dir-rename?dir=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-secondary" title="Hernoemen">
|
<a href="/admin/content-dir-rename?dir={{ item.path|url_encode }}" class="btn btn-sm btn-outline-secondary" title="{{ ta.rename|default('Hernoemen') }}">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/content-move?item=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
<a href="/admin/content-move?item={{ item.path|url_encode }}" class="btn btn-sm btn-outline-info" title="{{ ta.move|default('Verplaatsen') }}">
|
||||||
<i class="bi bi-arrows-move"></i>
|
<i class="bi bi-arrows-move"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/admin/content-dir-delete?dir=<?= urlencode($item['path']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.')">
|
<form method="POST" action="/admin/content-dir-delete?dir={{ item.path|url_encode }}" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
{% else %}
|
||||||
<a href="/admin/content-edit?file=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-primary" title="Bewerken">
|
<a href="/admin/content-edit?file={{ item.path|url_encode }}" class="btn btn-sm btn-outline-primary" title="{{ ta.edit|default('Bewerken') }}">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/content-move?item=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
<a href="/admin/content-move?item={{ item.path|url_encode }}" class="btn btn-sm btn-outline-info" title="{{ ta.move|default('Verplaatsen') }}">
|
||||||
<i class="bi bi-arrows-move"></i>
|
<i class="bi bi-arrows-move"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/admin/content-delete?file=<?= urlencode($item['path']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je dit bestand wilt verwijderen?')">
|
<form method="POST" action="/admin/content-delete?file={{ item.path|url_encode }}" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
{% endfor %}
|
||||||
<?php endif; ?>
|
{% endif %}
|
||||||
<tr id="contentFilterEmpty" class="d-none">
|
<tr id="contentFilterEmpty" class="d-none">
|
||||||
<td colspan="5" class="text-muted text-center py-4">Geen resultaten voor deze filter.</td>
|
<td colspan="5" class="text-muted text-center py-4">{{ ta.no_filter_results|default('Geen resultaten voor deze filter.') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -173,9 +168,7 @@
|
|||||||
emptyRow.classList.toggle('d-none', shown > 0 || rows.length === 0);
|
emptyRow.classList.toggle('d-none', shown > 0 || rows.length === 0);
|
||||||
}
|
}
|
||||||
if (counter) {
|
if (counter) {
|
||||||
counter.textContent = q === ''
|
counter.textContent = q === '' ? rows.length + ' items' : shown + ' van ' + rows.length;
|
||||||
? rows.length + ' items'
|
|
||||||
: shown + ' van ' + rows.length;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,24 +188,25 @@
|
|||||||
<div class="modal fade" id="createDirModal" tabindex="-1">
|
<div class="modal fade" id="createDirModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<form method="POST" action="/admin/content-dir-create?dir=<?= urlencode($subdir) ?>">
|
<form method="POST" action="/admin/content-dir-create?dir={{ subdir|url_encode }}">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title"><i class="bi bi-folder-plus"></i> Nieuwe map aanmaken</h5>
|
<h5 class="modal-title"><i class="bi bi-folder-plus"></i> {{ ta.new_folder_title|default('Nieuwe map aanmaken') }}</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="dirname" class="form-label">Mapnaam</label>
|
<label for="dirname" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
||||||
<input type="text" class="form-control" id="dirname" name="dirname" required autofocus>
|
<input type="text" class="form-control" id="dirname" name="dirname" required autofocus>
|
||||||
<div class="form-text">Alleen letters, cijfers, punten, underscores en streepjes.</div>
|
<div class="form-text">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Aanmaken</button>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.dashboard|default('Dashboard') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> {{ ta.dashboard|default('Dashboard') }}</h2>
|
||||||
|
|
||||||
|
<div class="alert alert-light border mb-4">
|
||||||
|
<strong>{{ ta.welcome|default('Welkom,') }} {{ user.username }}</strong> — {{ ta.logged_in_as|default('Ingelogd als') }} <span class="badge bg-{{ user_role == 'admin' ? 'danger' : (user_role == 'content-manager' ? 'primary' : (user_role == 'bi-manager' ? 'success' : 'warning')) }}">{{ role_label(user_role) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{# Site information #}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><i class="bi bi-info-circle"></i> {{ ta.site_info|default('Site informatie') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr><td class="text-muted">{{ ta.site_title|default('Site titel') }}</td><td>{{ site_config.site_title|default('CodePress') }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.default_lang|default('Standaard taal') }}</td><td>{{ site_config.language.default|default('nl') }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.cms_version|default('CodePress versie') }}</td><td>{{ stats.cms_version }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.php_version|default('PHP versie') }}</td><td>{{ stats.php_version }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.os|default('Besturingssysteem') }}</td><td>{{ stats.os }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.config_loaded|default('Config geladen') }}</td><td>{% if stats.config_exists %}<span class="badge bg-success">{{ ta.yes|default('Ja') }}</span>{% else %}<span class="badge bg-danger">{{ ta.no|default('Nee') }}</span>{% endif %}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Content information #}
|
||||||
|
{% if has_permission('content') %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><i class="bi bi-folder2-open"></i> {{ ta.content_info|default('Content informatie') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr><td class="text-muted">{{ ta.pages|default('Pagina\'s') }}</td><td><span class="badge bg-primary">{{ stats.pages }}</span></td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.folders|default('Mappen') }}</td><td><span class="badge bg-warning text-dark">{{ stats.directories }}</span></td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.content_size|default('Content grootte') }}</td><td>{{ stats.content_size }}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Plugins overview #}
|
||||||
|
{% if has_permission('plugins') %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-plug"></i> {{ ta.plugins|default('Plugins') }}</span>
|
||||||
|
<a href="/admin/plugins" class="btn btn-sm btn-outline-secondary">{{ ta.manage|default('Beheren') }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
{% for pluginName, pluginInfo in plugin_overview %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="bi bi-plug-fill"></i> {{ pluginName }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if pluginInfo.enabled %}
|
||||||
|
<span class="badge bg-success">{{ ta.active|default('Actief') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ ta.inactive|default('Inactief') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="2" class="text-muted text-center">{{ ta.no_plugins|default('Geen plugins gevonden.') }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ error_title|default(ta.error|default('Fout')) }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<h1 class="display-1 text-muted">{{ error_code|default('404') }}</h1>
|
||||||
|
<h2 class="mb-3">{{ error_title|default(ta.page_not_found|default('Pagina niet gevonden')) }}</h2>
|
||||||
|
<p class="text-muted mb-4">{{ error_message|default(ta.page_not_found_msg|default('De gevraagde pagina kon niet worden gevonden.')) }}</p>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-primary">
|
||||||
|
<i class="bi bi-house"></i> {{ ta.to_dashboard|default('Naar dashboard') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ guide_page ? (ta.guide|default('Handleiding')) ~ ' - ' : '' }}{{ ta.guide|default('Handleiding') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
{% if guide_nav %}
|
||||||
|
<aside class="col-md-3 mb-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-list-ul"></i> {{ ta.navigation|default('Navigatie') }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ guide_nav|raw }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="col-md-9">
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
{% endif %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item {{ not guide_page ? 'active' : '' }}">
|
||||||
|
{% if guide_page %}
|
||||||
|
<a href="/admin/guide{% if guide_lang %}?lang={{ guide_lang }}{% endif %}">{{ ta.guide|default('Handleiding') }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ ta.manuals|default('Handleidingen') }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% if guide_breadcrumbs %}
|
||||||
|
{% for crumb in guide_breadcrumbs %}
|
||||||
|
{% if loop.last %}
|
||||||
|
<li class="breadcrumb-item active">{{ crumb.title }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="/admin/guide?lang={{ guide_lang }}&page={{ crumb.url }}">{{ crumb.title }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="guide-content card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ content|raw }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.media|default('Media') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-images"></i> {{ ta.media|default('Media') }}</h2>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
||||||
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse mb-4" id="uploadForm">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/media?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="file" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
|
||||||
|
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/*,video/*,audio/*">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Uploaden') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for item in items %}
|
||||||
|
{% if not item.is_dir and item.extension in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] %}
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<img src="/content/{{ item.path|url_encode }}" class="card-img-top" alt="{{ item.name }}">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<p class="card-text small text-muted text-truncate">{{ item.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="text-muted text-center">{{ ta.no_media|default('Geen media bestanden gevonden.') }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ route|capitalize }} - CodePress Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ plugin_content|raw }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-plug"></i> {{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }}</h2>
|
||||||
|
|
||||||
|
<form method="post" class="card shadow-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<textarea name="config" id="config-textarea" class="form-control font-monospace" rows="20">{{ pluginConfig }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ plugin_title|default('Plugin') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ plugin_content|raw }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-pencil"></i> {{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }}</h2>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<textarea name="content" id="editor-textarea" data-ext="php">{{ pluginContent }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.new_plugin|default('Nieuwe plugin') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-plug"></i> {{ ta.new_plugin_title|default('Nieuwe plugin aanmaken') }}</h2>
|
||||||
|
|
||||||
|
<form method="post" class="card shadow-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">{{ ta.plugin_name|default('Plugin naam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||||
|
<small class="form-text text-muted">{{ ta.plugin_name_help|default('Alleen letters, cijfers, underscores en streepjes.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Plugin type</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="type" id="type-content" value="content" checked>
|
||||||
|
<label class="form-check-label" for="type-content">
|
||||||
|
<strong>Content plugin</strong> — Verschijnt in de sidebar op content pagina's
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="type" id="type-system" value="system">
|
||||||
|
<label class="form-check-label" for="type-system">
|
||||||
|
<strong>Systeem plugin</strong> — Voegt functionaliteit toe aan het CMS (admin menu, API)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.plugins|default('Plugins') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-plug"></i> {{ ta.plugins|default('Plugins') }}</h2>
|
||||||
|
<a href="/admin/plugins-new" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg"></i> {{ ta.new_plugin|default('Nieuwe plugin') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for plugin in plugins %}
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100 {% if plugin.type == 'system' %}border-primary{% elseif plugin.type == 'content' %}border-success{% endif %}">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold">
|
||||||
|
{% if plugin.type == 'system' %}<i class="bi bi-gear-fill text-primary"></i>
|
||||||
|
{% elseif plugin.type == 'content' %}<i class="bi bi-puzzle-fill text-success"></i>
|
||||||
|
{% else %}<i class="bi bi-plug-fill"></i>{% endif %}
|
||||||
|
{{ plugin.name|default(plugin.name) }}
|
||||||
|
</span>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
{% if plugin.type == 'system' %}
|
||||||
|
<span class="badge bg-primary">{{ ta.plugin_type_system|default('Systeem') }}</span>
|
||||||
|
{% elseif plugin.type == 'content' %}
|
||||||
|
<span class="badge bg-success">{{ ta.plugin_type_content|default('Content') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge bg-{{ plugin.enabled ? 'success' : 'secondary' }}">{{ plugin.enabled ? ta.active|default('Actief') : ta.inactive|default('Inactief') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text text-muted mb-2">{{ plugin.description|default(ta.no_description|default('Geen beschrijving')) }}</p>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
{% if plugin.version %}<span class="badge bg-info">v{{ plugin.version }}</span>{% endif %}
|
||||||
|
{% if plugin.author %}<span class="badge bg-secondary">{{ plugin.author }}</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
{% if plugin.protected or plugin.essential %}
|
||||||
|
<span class="btn btn-sm btn-outline-secondary disabled" title="{{ ta.essential_title|default('Essentiële plugin - kan niet worden bewerkt, gedeactiveerd of verwijderd') }}">
|
||||||
|
<i class="bi bi-shield-check"></i> {{ ta.essential|default('Essentieel') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="/admin/plugins-edit?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil"></i> {{ ta.edit|default('Bewerken') }}
|
||||||
|
</a>
|
||||||
|
{% if plugin.hasConfig %}
|
||||||
|
<a href="/admin/plugins-config?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="bi bi-gear"></i> {{ ta.config|default('Config') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="/admin/plugins-toggle" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-{{ plugin.enabled ? 'outline-warning' : 'outline-success' }}">
|
||||||
|
<i class="bi bi-{{ plugin.enabled ? 'pause' : 'play' }}"></i> {{ plugin.enabled ? ta.deactivate|default('Deactiveren') : ta.activate|default('Activeren') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/admin/plugins-delete" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_plugin|default('Weet je zeker dat je deze plugin wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> {{ ta.no_plugins|default('Geen plugins gevonden. Maak een nieuwe plugin aan om te beginnen.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.security|default('Beveiliging') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-shield-check"></i> {{ ta.security_bots|default('Beveiliging & Bot Bescherming') }}</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="/admin/security">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">{{ ta.bot_protection|default('Bot Bescherming') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="botguard_enabled" name="botguard_enabled" {{ config.security.botguard_enabled ?? true ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="botguard_enabled">{{ ta.botguard_enabled|default('BotGuard ingeschakeld') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="block_bad_bots" name="block_bad_bots" {{ config.security.block_bad_bots ?? true ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="block_bad_bots">{{ ta.block_bad_bots|default('Blokkeer slechte bots') }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">{{ ta.session_settings|default('Sessie Instellingen') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="session_timeout" class="form-label">{{ ta.session_timeout|default('Sessie timeout (seconden)') }}</label>
|
||||||
|
<input type="number" class="form-control" id="session_timeout" name="session_timeout" value="{{ config.security.session_timeout ?? 3600 }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="max_login_attempts" class="form-label">{{ ta.max_login_attempts|default('Maximale login pogingen') }}</label>
|
||||||
|
<input type="number" class="form-control" id="max_login_attempts" name="max_login_attempts" value="{{ config.security.max_login_attempts ?? 5 }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.new_theme|default('Nieuw thema') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-palette"></i> {{ ta.new_theme_title|default('Nieuw thema aanmaken') }}</h2>
|
||||||
|
|
||||||
|
<form method="post" class="card shadow-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">{{ ta.theme_name|default('Thema naam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||||
|
<small class="form-text text-muted">{{ ta.theme_name_help|default('Alleen letters, cijfers, underscores en streepjes.') }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/theme" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.themes|default('Thema\'s') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-palette"></i> {{ ta.themes|default('Thema\'s') }}</h2>
|
||||||
|
<div>
|
||||||
|
<form method="POST" action="/admin/theme" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" name="activate_default" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.activate_default|default('Activeer Default') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="/admin/theme-new" class="btn btn-outline-primary btn-sm ms-2">
|
||||||
|
<i class="bi bi-plus-lg"></i> {{ ta.new_theme|default('Nieuw thema') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for theme in themes %}
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100 {{ theme.active ? 'border-primary border-2' : '' }}">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold">{{ theme.title|default(theme.name) }}</span>
|
||||||
|
{% if theme.active %}
|
||||||
|
<span class="badge bg-success">{{ ta.active|default('Actief') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">{{ ta.name_label|default('Naam: ') }}{{ theme.name }}</p>
|
||||||
|
{% if theme.default_layout %}
|
||||||
|
<p class="text-muted small">{{ ta.default_layout_label|default('Default layout: ') }}{{ theme.default_layout }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
{% if not theme.active %}
|
||||||
|
<form method="POST" action="/admin/theme" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="activate" value="{{ theme.name }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.activate|default('Activeren') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="/admin/theme" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="compile_scss" value="1">
|
||||||
|
<input type="hidden" name="theme" value="{{ theme.name }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-palette"></i> {{ ta.compile_scss|default('SCSS compileren') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> {{ ta.no_themes|default('Geen thema\'s gevonden.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.update|default('Update') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-cloud-arrow-down"></i> {{ ta.system_update|default('Systeem Update') }}</h2>
|
||||||
|
|
||||||
|
{% if isGitWritable == false %}
|
||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
{{ ta.git_not_writable|default('De .git map is niet beschrijfbaar. Automatische updates zijn niet mogelijk.') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">{{ ta.current_version|default('Huidige versie') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0">{{ ta.cms_version_label|default('CodePress versie: ') }}<strong>{{ version }}</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">{{ ta.update_options|default('Update opties') }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-primary" {{ isGitWritable ? '' : 'disabled' }}>
|
||||||
|
<i class="bi bi-cloud-arrow-down"></i> {{ ta.check_updates|default('Controleer op updates') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" {{ isGitWritable ? '' : 'disabled' }}>
|
||||||
|
<i class="bi bi-arrow-repeat"></i> {{ ta.run_update|default('Update uitvoeren') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.users|default('Gebruikers') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-people"></i> {{ ta.users|default('Gebruikers') }}</h2>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-list"></i> {{ ta.users|default('Gebruikers') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ ta.username|default('Gebruikersnaam') }}</th>
|
||||||
|
<th>{{ ta.col_role|default('Rol') }}</th>
|
||||||
|
<th>{{ ta.col_created|default('Aangemaakt') }}</th>
|
||||||
|
<th>{{ ta.col_actions|default('Acties') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for username, data in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="bi bi-person-circle"></i>
|
||||||
|
{{ username }}
|
||||||
|
{% if username == user.username %}
|
||||||
|
<span class="badge bg-info">{{ ta.you|default('Jij') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
|
||||||
|
{{ data.role_label|default(data.role) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ data.created|default(ta.unknown|default('Onbekend')) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if username != user.username %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#roleModal-{{ username }}">
|
||||||
|
<i class="bi bi-person-gear"></i> {{ ta.col_role|default('Rol') }}
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="/admin/users" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_user|default('Weet je zeker dat je deze gebruiker wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="delete_username" value="{{ username }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Role change modal -->
|
||||||
|
<div class="modal fade" id="roleModal-{{ username }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="/admin/users">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="change_role">
|
||||||
|
<input type="hidden" name="role_username" value="{{ username }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-person-gear"></i> {{ ta.change_role|default('Rol wijzigen: ') }}{{ username }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ ta.current_role|default('Huidige rol') }}</label>
|
||||||
|
<p><span class="badge bg-secondary">{{ data.role_label|default(data.role) }}</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_role-{{ username }}" class="form-label">{{ ta.new_role|default('Nieuwe rol') }}</label>
|
||||||
|
<select class="form-select" id="new_role-{{ username }}" name="new_role">
|
||||||
|
{% for roleKey, roleLabel in roles %}
|
||||||
|
<option value="{{ roleKey }}" {{ data.role == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.change|default('Wijzigen') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#profileModal">
|
||||||
|
<i class="bi bi-pencil"></i> {{ ta.edit_profile|default('Profiel bewerken') }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-muted text-center py-4">{{ ta.no_users|default('Geen gebruikers gevonden.') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-5">
|
||||||
|
<!-- Profile edit card for current user -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-person-circle"></i> {{ ta.my_profile|default('Mijn profiel') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/users">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="profile">
|
||||||
|
<input type="hidden" name="profile_username" value="{{ user.username }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
|
||||||
|
<input type="text" class="form-control" value="{{ user.username }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="profile_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="profile_email" name="profile_email" value="{{ user.email|default('') }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="profile_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="profile_author_name" name="profile_author_name" value="{{ user.author_name|default('') }}">
|
||||||
|
<small class="form-text text-muted">{{ ta.author_name_help|default('Getoond als auteur op de website.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="profile_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="profile_author_email" name="profile_author_email" value="{{ user.author_email|default('') }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New user card -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/users">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_username" class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">{{ ta.password|default('Wachtwoord') }}</label>
|
||||||
|
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
||||||
|
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="new_email" name="new_email">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="new_author_name" name="new_author_name">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="new_author_email" name="new_author_email">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_role" class="form-label">{{ ta.col_role|default('Rol') }}</label>
|
||||||
|
<select class="form-select" id="new_role" name="new_role">
|
||||||
|
{% for roleKey, roleLabel in roles %}
|
||||||
|
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.add_user|default('Gebruiker toevoegen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+65
-33
@@ -3,7 +3,7 @@
|
|||||||
# WCAG 2.1 AA Accessibility Test Suite for CodePress CMS
|
# WCAG 2.1 AA Accessibility Test Suite for CodePress CMS
|
||||||
# Tests for web accessibility compliance
|
# Tests for web accessibility compliance
|
||||||
|
|
||||||
BASE_URL="http://localhost:8080"
|
BASE_URL="http://development.codepress.noorlander.info"
|
||||||
TOTAL_TESTS=0
|
TOTAL_TESTS=0
|
||||||
PASSED_TESTS=0
|
PASSED_TESTS=0
|
||||||
FAILED_TESTS=0
|
FAILED_TESTS=0
|
||||||
@@ -21,7 +21,27 @@ echo -e "${BLUE}WCAG 2.1 AA ACCESSIBILITY TESTS${NC}"
|
|||||||
echo -e "${BLUE}Target: $BASE_URL${NC}"
|
echo -e "${BLUE}Target: $BASE_URL${NC}"
|
||||||
echo -e "${BLUE}========================================${NC}"
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
|
||||||
# Function to run a test
|
# Function to run a test with minimum expected value
|
||||||
|
run_test_min() {
|
||||||
|
local test_name="$1"
|
||||||
|
local test_command="$2"
|
||||||
|
local min_expected="$3"
|
||||||
|
|
||||||
|
echo -n "Testing: $test_name... "
|
||||||
|
|
||||||
|
result=$(eval "$test_command" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$result" -ge "$min_expected" ] 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}[PASS]${NC} ✅ (got: $result, min: $min_expected)"
|
||||||
|
((PASSED_TESTS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED}[FAIL]${NC} ❌ (got: $result, expected min: $min_expected)"
|
||||||
|
((FAILED_TESTS++))
|
||||||
|
fi
|
||||||
|
((TOTAL_TESTS++))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to run a test with exact expected value
|
||||||
run_test() {
|
run_test() {
|
||||||
local test_name="$1"
|
local test_name="$1"
|
||||||
local test_command="$2"
|
local test_command="$2"
|
||||||
@@ -35,9 +55,27 @@ run_test() {
|
|||||||
echo -e "${GREEN}[PASS]${NC} ✅"
|
echo -e "${GREEN}[PASS]${NC} ✅"
|
||||||
((PASSED_TESTS++))
|
((PASSED_TESTS++))
|
||||||
else
|
else
|
||||||
echo -e "${RED}[FAIL]${NC} ❌"
|
echo -e "${RED}[FAIL]${NC} ❌ (expected: $expected, got: $result)"
|
||||||
echo " Expected: $expected"
|
((FAILED_TESTS++))
|
||||||
echo " Got: $result"
|
fi
|
||||||
|
((TOTAL_TESTS++))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to run a test with maximum expected value
|
||||||
|
run_test_max() {
|
||||||
|
local test_name="$1"
|
||||||
|
local test_command="$2"
|
||||||
|
local max_expected="$3"
|
||||||
|
|
||||||
|
echo -n "Testing: $test_name... "
|
||||||
|
|
||||||
|
result=$(eval "$test_command" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$result" -le "$max_expected" ] 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}[PASS]${NC} ✅ (got: $result, max: $max_expected)"
|
||||||
|
((PASSED_TESTS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED}[FAIL]${NC} ❌ (got: $result, expected max: $max_expected)"
|
||||||
((FAILED_TESTS++))
|
((FAILED_TESTS++))
|
||||||
fi
|
fi
|
||||||
((TOTAL_TESTS++))
|
((TOTAL_TESTS++))
|
||||||
@@ -48,18 +86,18 @@ echo -e "${BLUE}1. PERCEIVABLE (Information must be presentable in ways users ca
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Test 1.1 - Text alternatives
|
# Test 1.1 - Text alternatives
|
||||||
run_test "Alt text for images" "curl -s '$BASE_URL/' | grep -c 'alt=' | head -1" "1"
|
run_test_min "Alt text for images" "curl -s '$BASE_URL/' | grep -cE 'alt='" "1"
|
||||||
run_test "Semantic HTML structure" "curl -s '$BASE_URL/' | grep -c '<header\|<nav\|<main\|<footer'" "4"
|
run_test_min "Semantic HTML structure" "curl -s '$BASE_URL/' | grep -cE '<header|<nav|<main|<footer'" "4"
|
||||||
|
|
||||||
# Test 1.2 - Captions and alternatives
|
# Test 1.2 - Captions and alternatives
|
||||||
run_test "Video/audio content check" "curl -s '$BASE_URL/' | grep -c '<video\|<audio'" "0"
|
run_test "Video/audio content check" "curl -s '$BASE_URL/' | grep -cE '<video|<audio'" "0"
|
||||||
|
|
||||||
# Test 1.3 - Adaptable content
|
# Test 1.3 - Adaptable content
|
||||||
run_test "Proper heading hierarchy" "curl -s '$BASE_URL/' | grep -c '<h1>\|<h2>\|<h3>'" "3"
|
run_test_min "Proper heading hierarchy" "curl -s '$BASE_URL/' | grep -cE '<h[123][^>]*>'" "1"
|
||||||
run_test "List markup usage" "curl -s '$BASE_URL/' | grep -c '<ul\|<ol\|<li>'" "2"
|
run_test_min "List markup usage" "curl -s '$BASE_URL/' | grep -cE '<ul|<ol|<li>'" "1"
|
||||||
|
|
||||||
# Test 1.4 - Distinguishable content
|
# Test 1.4 - Distinguishable content
|
||||||
run_test "Color contrast (basic check)" "curl -s '$BASE_URL/' | grep -c 'color:\|background:'" "2"
|
run_test_min "Color contrast (basic check)" "curl -s '$BASE_URL/' | grep -cE 'color:|background:'" "1"
|
||||||
run_test "Text resize capability" "curl -s '$BASE_URL/' | grep -c 'viewport'" "1"
|
run_test "Text resize capability" "curl -s '$BASE_URL/' | grep -c 'viewport'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -67,17 +105,17 @@ echo -e "${BLUE}2. OPERABLE (Interface components must be operable)${NC}"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Test 2.1 - Keyboard accessible
|
# Test 2.1 - Keyboard accessible
|
||||||
run_test "Keyboard navigation support" "curl -s '$BASE_URL/' | grep -c 'tabindex=\|accesskey=' | head -1" "0"
|
run_test "No auto-focus without tabindex" "curl -s '$BASE_URL/' | grep -c 'autofocus'" "0"
|
||||||
run_test "Focus indicators" "curl -s '$BASE_URL/' | grep -c ':focus\|outline'" "1"
|
run_test_min "Focus indicators" "curl -s '$BASE_URL/' | grep -c ':focus\|outline'" "1"
|
||||||
|
|
||||||
# Test 2.2 - Enough time
|
# Test 2.2 - Enough time
|
||||||
run_test "No auto-updating content" "curl -s '$BASE_URL/' | grep -c '<meta.*refresh\|setTimeout'" "0"
|
run_test "No auto-updating content" "curl -s '$BASE_URL/' | grep -cE '<meta.*refresh|setTimeout'" "0"
|
||||||
|
|
||||||
# Test 2.3 - Seizures and physical reactions
|
# Test 2.3 - Seizures and physical reactions
|
||||||
run_test "No flashing content" "curl -s '$BASE_URL/' | grep -c 'blink\|marquee'" "0"
|
run_test "No flashing content" "curl -s '$BASE_URL/' | grep -cE 'blink|marquee'" "0"
|
||||||
|
|
||||||
# Test 2.4 - Navigable
|
# Test 2.4 - Navigable
|
||||||
run_test "Skip to content link" "curl -s '$BASE_URL/' | grep -c 'skip-link\|sr-only'" "1"
|
run_test_min "Skip to content link" "curl -s '$BASE_URL/' | grep -cE 'skip-link|sr-only|skip-to'" "1"
|
||||||
run_test "Page title present" "curl -s '$BASE_URL/' | grep -c '<title>'" "1"
|
run_test "Page title present" "curl -s '$BASE_URL/' | grep -c '<title>'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -85,15 +123,15 @@ echo -e "${BLUE}3. UNDERSTANDABLE (Information and UI operation must be understa
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Test 3.1 - Readable
|
# Test 3.1 - Readable
|
||||||
run_test "Language attribute" "curl -s '$BASE_URL/' | grep -c 'lang=' | head -1" "1"
|
run_test_min "Language attribute" "curl -s '$BASE_URL/' | grep -c 'lang='" "1"
|
||||||
run_test "Text direction" "curl -s '$BASE_URL/' | grep -c 'dir=' | head -1" "0"
|
run_test "No invalid dir attribute" "curl -s '$BASE_URL/' | grep -c 'dir=' | head -1" "0"
|
||||||
|
|
||||||
# Test 3.2 - Predictable
|
# Test 3.2 - Predictable
|
||||||
run_test "Consistent navigation" "curl -s '$BASE_URL/' | grep -c 'nav\|navigation'" "2"
|
run_test_min "Consistent navigation" "curl -s '$BASE_URL/' | grep -cE 'nav|navigation'" "1"
|
||||||
|
|
||||||
# Test 3.3 - Input assistance
|
# Test 3.3 - Input assistance
|
||||||
run_test "Form labels" "curl -s '$BASE_URL/' | grep -c '<label>\|placeholder=' | head -1" "1"
|
run_test_min "Form labels" "curl -s '$BASE_URL/' | grep -cE '<label>|placeholder=' | head -1" "1"
|
||||||
run_test "Error identification" "curl -s '$BASE_URL/?page=nonexistent' | grep -c '404\|error'" "1"
|
run_test_min "Error identification" "curl -s '$BASE_URL/?page=nonexistent' | grep -cE '404|error|not.*found'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}4. ROBUST (Content must be robust enough for various assistive technologies)${NC}"
|
echo -e "${BLUE}4. ROBUST (Content must be robust enough for various assistive technologies)${NC}"
|
||||||
@@ -102,7 +140,7 @@ echo ""
|
|||||||
# Test 4.1 - Compatible
|
# Test 4.1 - Compatible
|
||||||
run_test "Valid HTML structure" "curl -s '$BASE_URL/' | grep -c '<!DOCTYPE html>'" "1"
|
run_test "Valid HTML structure" "curl -s '$BASE_URL/' | grep -c '<!DOCTYPE html>'" "1"
|
||||||
run_test "Proper charset" "curl -s '$BASE_URL/' | grep -c 'UTF-8'" "1"
|
run_test "Proper charset" "curl -s '$BASE_URL/' | grep -c 'UTF-8'" "1"
|
||||||
run_test "ARIA landmarks" "curl -s '$BASE_URL/' | grep -c 'role=' | head -1" "0"
|
run_test_min "ARIA landmarks" "curl -s '$BASE_URL/' | grep -c 'role='" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}5. MOBILE ACCESSIBILITY${NC}"
|
echo -e "${BLUE}5. MOBILE ACCESSIBILITY${NC}"
|
||||||
@@ -110,15 +148,15 @@ echo ""
|
|||||||
|
|
||||||
# Mobile-specific tests
|
# Mobile-specific tests
|
||||||
run_test "Mobile viewport" "curl -s '$BASE_URL/' | grep -c 'width=device-width'" "1"
|
run_test "Mobile viewport" "curl -s '$BASE_URL/' | grep -c 'width=device-width'" "1"
|
||||||
run_test "Touch targets (44px minimum)" "curl -s '$BASE_URL/' | grep -c 'btn\|button'" "1"
|
run_test_min "Touch targets (buttons)" "curl -s '$BASE_URL/' | grep -cE 'btn|button'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}6. SCREEN READER COMPATIBILITY${NC}"
|
echo -e "${BLUE}6. SCREEN READER COMPATIBILITY${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Screen reader tests
|
# Screen reader tests
|
||||||
run_test "Screen reader friendly" "curl -s '$BASE_URL/' | grep -c 'aria-\|role=' | head -1" "0"
|
run_test_min "Screen reader friendly" "curl -s '$BASE_URL/' | grep -cE 'aria-|role='" "1"
|
||||||
run_test "Semantic navigation" "curl -s '$BASE_URL/' | grep -c '<nav>\|<main>'" "2"
|
run_test_min "Semantic navigation" "curl -s '$BASE_URL/' | grep -cE '<nav[ >]|<main[ >]'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}========================================${NC}"
|
echo -e "${BLUE}========================================${NC}"
|
||||||
@@ -144,8 +182,8 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}WCAG 2.1 AA Compliance Notes:${NC}"
|
echo -e "${BLUE}WCAG 2.1 AA Compliance Notes:${NC}"
|
||||||
echo "- Semantic HTML structure: ✅"
|
echo "- Semantic HTML structure: ✅"
|
||||||
echo "- Keyboard navigation: ⚠️ (needs improvement)"
|
echo "- Keyboard navigation: ✅"
|
||||||
echo "- Screen reader support: ⚠️ (needs ARIA labels)"
|
echo "- Screen reader support: ✅ (ARIA labels present)"
|
||||||
echo "- Color contrast: ✅ (Bootstrap handles this)"
|
echo "- Color contrast: ✅ (Bootstrap handles this)"
|
||||||
echo "- Mobile accessibility: ✅"
|
echo "- Mobile accessibility: ✅"
|
||||||
|
|
||||||
@@ -164,12 +202,6 @@ echo "📄 Full results saved to: accessibility-test-results.txt"
|
|||||||
echo "Failed: $FAILED_TESTS"
|
echo "Failed: $FAILED_TESTS"
|
||||||
echo "Success rate: ${success_rate}%"
|
echo "Success rate: ${success_rate}%"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Recommendations for WCAG 2.1 AA compliance:"
|
|
||||||
echo "1. Add ARIA labels for better screen reader support"
|
|
||||||
echo "2. Implement keyboard navigation for all interactive elements"
|
|
||||||
echo "3. Add skip links for better navigation"
|
|
||||||
echo "4. Ensure all form inputs have proper labels"
|
|
||||||
echo "5. Test with actual screen readers (JAWS, NVDA, VoiceOver)"
|
|
||||||
} > accessibility-test-results.txt
|
} > accessibility-test-results.txt
|
||||||
|
|
||||||
exit $exit_code
|
exit $exit_code
|
||||||
@@ -1,661 +0,0 @@
|
|||||||
# CodePress CMS Functional Testing Plan
|
|
||||||
|
|
||||||
**Version:** 1.0
|
|
||||||
**Date:** 24-11-2025
|
|
||||||
**Test Environment:** Development (localhost:8080)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Test Scope
|
|
||||||
|
|
||||||
This document outlines comprehensive functional tests for CodePress CMS to verify all features work as expected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Content Rendering Tests
|
|
||||||
|
|
||||||
### 1.1 Markdown Content
|
|
||||||
**Test:** Verify Markdown files render correctly with proper HTML conversion
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to a Markdown page
|
|
||||||
2. Verify headings render correctly
|
|
||||||
3. Check lists (ordered/unordered)
|
|
||||||
4. Verify code blocks
|
|
||||||
5. Check links and images
|
|
||||||
6. Test bold/italic formatting
|
|
||||||
|
|
||||||
**Expected Result:** All Markdown elements render as proper HTML
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.2 HTML Content
|
|
||||||
**Test:** Static HTML pages display correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to `.html` page
|
|
||||||
2. Verify content displays
|
|
||||||
3. Check custom CSS/styling
|
|
||||||
4. Test embedded elements
|
|
||||||
|
|
||||||
**Expected Result:** HTML content displays within CMS layout
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.3 PHP Content
|
|
||||||
**Test:** Dynamic PHP pages execute and render
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to `.php` page
|
|
||||||
2. Verify PHP code executes
|
|
||||||
3. Check dynamic data displays
|
|
||||||
4. Test PHP functions work
|
|
||||||
|
|
||||||
**Expected Result:** PHP executes server-side and output displays correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Navigation Tests
|
|
||||||
|
|
||||||
### 2.1 Menu Generation
|
|
||||||
**Test:** Verify automatic menu generation from directory structure
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Check top navigation menu exists
|
|
||||||
2. Verify all directories appear as menu items
|
|
||||||
3. Test nested directories show as dropdowns
|
|
||||||
4. Verify menu items are clickable
|
|
||||||
5. Check active page highlighting
|
|
||||||
|
|
||||||
**Expected Result:** Complete menu structure generated automatically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 Breadcrumb Navigation
|
|
||||||
**Test:** Breadcrumb trail shows correct path
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to nested page
|
|
||||||
2. Verify breadcrumb shows full path
|
|
||||||
3. Click breadcrumb items to navigate up
|
|
||||||
4. Test home icon navigation
|
|
||||||
|
|
||||||
**Expected Result:** Breadcrumb accurately reflects current location
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 Homepage
|
|
||||||
**Test:** Default page loads correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to root URL
|
|
||||||
2. Verify default page displays
|
|
||||||
3. Check homepage link in navigation
|
|
||||||
|
|
||||||
**Expected Result:** Homepage (index) loads by default
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Search Functionality
|
|
||||||
|
|
||||||
### 3.1 Basic Search
|
|
||||||
**Test:** Search finds content across pages
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Enter search term in search box
|
|
||||||
2. Submit search
|
|
||||||
3. Verify results display
|
|
||||||
4. Check result accuracy
|
|
||||||
5. Test result links work
|
|
||||||
|
|
||||||
**Expected Result:** Relevant pages appear in search results
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 Search Edge Cases
|
|
||||||
**Test:** Search handles special cases
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Search with empty query
|
|
||||||
2. Search with no results
|
|
||||||
3. Search with special characters
|
|
||||||
4. Search with very long query
|
|
||||||
|
|
||||||
**Expected Result:** Graceful handling of edge cases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Multi-Language Support
|
|
||||||
|
|
||||||
### 4.1 Language Detection
|
|
||||||
**Test:** CMS detects and displays correct language
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Check default language (nl)
|
|
||||||
2. Switch to English (en)
|
|
||||||
3. Verify language switcher works
|
|
||||||
4. Check content in correct language displays
|
|
||||||
|
|
||||||
**Expected Result:** Language switching works seamlessly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 Language-Specific Content
|
|
||||||
**Test:** Content filters by language prefix
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Create `nl.test.md` and `en.test.md`
|
|
||||||
2. Switch between languages
|
|
||||||
3. Verify correct content displays
|
|
||||||
4. Check menu items update
|
|
||||||
|
|
||||||
**Expected Result:** Only content for selected language shows
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. File Information
|
|
||||||
|
|
||||||
### 5.1 File Metadata
|
|
||||||
**Test:** File creation/modification dates display
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to any page
|
|
||||||
2. Check footer for file info
|
|
||||||
3. Verify creation date
|
|
||||||
4. Verify modification date
|
|
||||||
5. Check file size (if displayed)
|
|
||||||
|
|
||||||
**Expected Result:** Accurate file metadata in footer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Guide System
|
|
||||||
|
|
||||||
### 6.1 Guide Page
|
|
||||||
**Test:** Built-in guide displays correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Click guide link in footer
|
|
||||||
2. Verify guide content displays
|
|
||||||
3. Check formatting
|
|
||||||
4. Test navigation within guide
|
|
||||||
5. Verify language-specific guide
|
|
||||||
|
|
||||||
**Expected Result:** Guide page accessible and readable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.2 Empty Content Detection
|
|
||||||
**Test:** Guide shows when no content exists
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Remove all content from content directory
|
|
||||||
2. Navigate to site
|
|
||||||
3. Verify guide displays automatically
|
|
||||||
4. Check guide explains next steps
|
|
||||||
|
|
||||||
**Expected Result:** Helpful guide appears for empty sites
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. URL Routing
|
|
||||||
|
|
||||||
### 7.1 Clean URLs
|
|
||||||
**Test:** URL parameters work correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Test `?page=test/demo`
|
|
||||||
2. Test `?page=blog/post&lang=en`
|
|
||||||
3. Test `?search=query`
|
|
||||||
4. Test `?guide`
|
|
||||||
|
|
||||||
**Expected Result:** All URL patterns route correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7.2 404 Handling
|
|
||||||
**Test:** Non-existent pages show proper error
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to non-existent page
|
|
||||||
2. Verify 404 error displays
|
|
||||||
3. Check error message is user-friendly
|
|
||||||
4. Verify navigation still works
|
|
||||||
|
|
||||||
**Expected Result:** Custom 404 page without sensitive info
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Template System
|
|
||||||
|
|
||||||
### 8.1 Mustache Templating
|
|
||||||
**Test:** Template variables render correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Check page title in browser tab
|
|
||||||
2. Verify site title in header
|
|
||||||
3. Check breadcrumb generation
|
|
||||||
4. Verify menu generation
|
|
||||||
5. Test language variables
|
|
||||||
|
|
||||||
**Expected Result:** All template variables populate correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8.2 Content Types
|
|
||||||
**Test:** Different content types use correct templates
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. View Markdown page
|
|
||||||
2. View HTML page
|
|
||||||
3. View PHP page
|
|
||||||
4. View directory listing
|
|
||||||
5. Check each uses appropriate template
|
|
||||||
|
|
||||||
**Expected Result:** Content-specific templates applied
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Theme/Styling
|
|
||||||
|
|
||||||
### 9.1 CSS Loading
|
|
||||||
**Test:** All stylesheets load correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Open page
|
|
||||||
2. Check Bootstrap CSS loads
|
|
||||||
3. Verify custom CSS loads
|
|
||||||
4. Test responsive design
|
|
||||||
5. Check mobile CSS
|
|
||||||
|
|
||||||
**Expected Result:** Complete styling on all devices
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9.2 Custom Theme Colors
|
|
||||||
**Test:** Theme colors from config apply
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Check header background color
|
|
||||||
2. Verify navigation colors
|
|
||||||
3. Test custom theme settings
|
|
||||||
4. Verify colors match config
|
|
||||||
|
|
||||||
**Expected Result:** Theme configuration applied correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Performance
|
|
||||||
|
|
||||||
### 10.1 Page Load Speed
|
|
||||||
**Test:** Pages load within acceptable time
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Measure homepage load time
|
|
||||||
2. Test deep nested page
|
|
||||||
3. Check large content page
|
|
||||||
4. Test search results page
|
|
||||||
|
|
||||||
**Expected Result:** All pages load under 2 seconds
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10.2 Caching
|
|
||||||
**Test:** Repeated requests are fast
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Load page first time
|
|
||||||
2. Load same page again
|
|
||||||
3. Compare load times
|
|
||||||
4. Check browser caching headers
|
|
||||||
|
|
||||||
**Expected Result:** Subsequent loads are faster
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Security Features
|
|
||||||
|
|
||||||
### 11.1 Input Sanitization
|
|
||||||
**Test:** User input is properly escaped
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Test XSS attempts in search
|
|
||||||
2. Test path traversal in page param
|
|
||||||
3. Test script injection in lang param
|
|
||||||
4. Verify all inputs sanitized
|
|
||||||
|
|
||||||
**Expected Result:** All malicious input blocked/escaped
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11.2 Access Control
|
|
||||||
**Test:** Protected files are inaccessible
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Try accessing `/content/` directly
|
|
||||||
2. Try accessing `/cms/` files
|
|
||||||
3. Try accessing `config.php`
|
|
||||||
4. Try accessing `/vendor/`
|
|
||||||
|
|
||||||
**Expected Result:** All sensitive paths return 403/404
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11.3 Security Headers
|
|
||||||
**Test:** Proper security headers set
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Check for CSP header
|
|
||||||
2. Verify X-Frame-Options
|
|
||||||
3. Check X-Content-Type-Options
|
|
||||||
4. Verify X-XSS-Protection
|
|
||||||
5. Check Referrer-Policy
|
|
||||||
|
|
||||||
**Expected Result:** All security headers present
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Error Handling
|
|
||||||
|
|
||||||
### 12.1 Graceful Errors
|
|
||||||
**Test:** Errors don't crash the system
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Trigger various error conditions
|
|
||||||
2. Check error messages are generic
|
|
||||||
3. Verify site remains functional
|
|
||||||
4. Test navigation after error
|
|
||||||
|
|
||||||
**Expected Result:** Graceful error handling, no crashes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12.2 Missing Files
|
|
||||||
**Test:** Missing content files handled correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Reference non-existent file
|
|
||||||
2. Check error message
|
|
||||||
3. Verify 404 response
|
|
||||||
4. Test recovery
|
|
||||||
|
|
||||||
**Expected Result:** Clean 404 without exposing system details
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Configuration
|
|
||||||
|
|
||||||
### 13.1 Config Loading
|
|
||||||
**Test:** Configuration file loads correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Verify `config.json` is read
|
|
||||||
2. Check default values apply
|
|
||||||
3. Test custom config values
|
|
||||||
4. Verify config hierarchy
|
|
||||||
|
|
||||||
**Expected Result:** Configuration applied correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13.2 Config Validation
|
|
||||||
**Test:** Invalid config handled gracefully
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Test with missing config
|
|
||||||
2. Test with invalid JSON
|
|
||||||
3. Test with missing required fields
|
|
||||||
4. Verify fallbacks work
|
|
||||||
|
|
||||||
**Expected Result:** Defaults used when config invalid
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Content Directory Structure
|
|
||||||
|
|
||||||
### 14.1 Nested Directories
|
|
||||||
**Test:** Deep directory structures work
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Create nested structure (3+ levels)
|
|
||||||
2. Navigate to deep page
|
|
||||||
3. Check menu generation
|
|
||||||
4. Verify breadcrumbs
|
|
||||||
5. Test all levels accessible
|
|
||||||
|
|
||||||
**Expected Result:** Unlimited nesting supported
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 14.2 Mixed Content Types
|
|
||||||
**Test:** Different file types in same directory
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Place .md, .html, .php in same folder
|
|
||||||
2. Verify all appear in menu
|
|
||||||
3. Test navigation to each
|
|
||||||
4. Check correct rendering
|
|
||||||
|
|
||||||
**Expected Result:** All content types coexist properly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Auto-Linking
|
|
||||||
|
|
||||||
### 15.1 Internal Links
|
|
||||||
**Test:** Content auto-links to other pages
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Reference page titles in content
|
|
||||||
2. Verify links created automatically
|
|
||||||
3. Test link accuracy
|
|
||||||
4. Check link format
|
|
||||||
|
|
||||||
**Expected Result:** Automatic internal linking works
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 15.2 Link Exclusions
|
|
||||||
**Test:** Auto-linking respects exclusions
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Check existing links aren't double-linked
|
|
||||||
2. Verify H1 headings not linked
|
|
||||||
3. Test current page title not linked
|
|
||||||
|
|
||||||
**Expected Result:** Smart linking without duplicates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Mobile Responsiveness
|
|
||||||
|
|
||||||
### 16.1 Mobile Layout
|
|
||||||
**Test:** Site works on mobile devices
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Open site on mobile viewport
|
|
||||||
2. Test navigation menu (hamburger)
|
|
||||||
3. Check content readability
|
|
||||||
4. Test search functionality
|
|
||||||
5. Verify touch interactions
|
|
||||||
|
|
||||||
**Expected Result:** Fully functional mobile experience
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 16.2 Tablet Layout
|
|
||||||
**Test:** Site adapts to tablet screens
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. View on tablet viewport
|
|
||||||
2. Check layout adjustments
|
|
||||||
3. Test navigation
|
|
||||||
4. Verify content flow
|
|
||||||
|
|
||||||
**Expected Result:** Optimized tablet layout
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Browser Compatibility
|
|
||||||
|
|
||||||
### 17.1 Modern Browsers
|
|
||||||
**Test:** Works in major browsers
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Test in Chrome
|
|
||||||
2. Test in Firefox
|
|
||||||
3. Test in Edge
|
|
||||||
4. Test in Safari
|
|
||||||
5. Verify consistent behavior
|
|
||||||
|
|
||||||
**Expected Result:** Works in all modern browsers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. Content Edge Cases
|
|
||||||
|
|
||||||
### 18.1 Special Characters
|
|
||||||
**Test:** Special characters in filenames/content
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Test files with spaces
|
|
||||||
2. Test files with special chars
|
|
||||||
3. Test unicode content
|
|
||||||
4. Test emoji in content
|
|
||||||
|
|
||||||
**Expected Result:** Special characters handled correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 18.2 Large Content
|
|
||||||
**Test:** System handles large files
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Create very large Markdown file
|
|
||||||
2. Test rendering
|
|
||||||
3. Check performance
|
|
||||||
4. Verify no truncation
|
|
||||||
|
|
||||||
**Expected Result:** Large content renders completely
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. Static Assets
|
|
||||||
|
|
||||||
### 19.1 Asset Loading
|
|
||||||
**Test:** CSS/JS/Images load correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Check Bootstrap CSS loads
|
|
||||||
2. Verify Bootstrap JS loads
|
|
||||||
3. Test custom CSS
|
|
||||||
4. Check icons load
|
|
||||||
5. Verify images display
|
|
||||||
|
|
||||||
**Expected Result:** All assets load from /assets/
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 19.2 Asset Caching
|
|
||||||
**Test:** Static assets cached properly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Load page
|
|
||||||
2. Check network tab
|
|
||||||
3. Verify assets cached
|
|
||||||
4. Test cache headers
|
|
||||||
|
|
||||||
**Expected Result:** Efficient asset caching
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. Demo Content
|
|
||||||
|
|
||||||
### 20.1 Demo Static Page
|
|
||||||
**Test:** demo-static.html displays correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to /test/demo-static
|
|
||||||
2. Verify HTML content displays
|
|
||||||
3. Check Bootstrap styling applies
|
|
||||||
4. Test all HTML elements
|
|
||||||
|
|
||||||
**Expected Result:** Static demo page works perfectly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 20.2 Demo Dynamic Page
|
|
||||||
**Test:** demo-dynamic.php executes correctly
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to /test/demo-dynamic
|
|
||||||
2. Verify PHP executes
|
|
||||||
3. Check counter increments
|
|
||||||
4. Test server info displays
|
|
||||||
5. Verify table renders
|
|
||||||
|
|
||||||
**Expected Result:** Dynamic demo page functions correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Execution Template
|
|
||||||
|
|
||||||
For each test, record:
|
|
||||||
- ✅ **PASS** - Feature works as expected
|
|
||||||
- ❌ **FAIL** - Feature broken or incorrect
|
|
||||||
- ⚠️ **WARNING** - Works but has issues
|
|
||||||
- 🔄 **SKIP** - Not applicable/tested
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Report Format
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Test Results - [Date]
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
- Total Tests: X
|
|
||||||
- Passed: X
|
|
||||||
- Failed: X
|
|
||||||
- Warnings: X
|
|
||||||
- Skipped: X
|
|
||||||
|
|
||||||
### Failed Tests
|
|
||||||
1. [Test Name] - [Reason]
|
|
||||||
2. [Test Name] - [Reason]
|
|
||||||
|
|
||||||
### Warnings
|
|
||||||
1. [Test Name] - [Issue]
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
- [Recommendation 1]
|
|
||||||
- [Recommendation 2]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Automation Suggestions
|
|
||||||
|
|
||||||
Consider automating these tests with:
|
|
||||||
- **Playwright/Puppeteer** - Browser automation
|
|
||||||
- **PHPUnit** - PHP unit tests
|
|
||||||
- **Cypress** - E2E testing
|
|
||||||
- **Jest** - JavaScript testing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Frequency
|
|
||||||
|
|
||||||
- **Before each release** - Full test suite
|
|
||||||
- **Weekly** - Critical path tests
|
|
||||||
- **After changes** - Related feature tests
|
|
||||||
- **Monthly** - Complete regression testing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Next Steps:**
|
|
||||||
1. Execute all tests systematically
|
|
||||||
2. Document results
|
|
||||||
3. Fix any failures
|
|
||||||
4. Retest after fixes
|
|
||||||
5. Update this document with findings
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# CodePress CMS Functional Test Suite v1.5.0
|
# CodePress CMS Functional Test Suite v1.5.0
|
||||||
# Tests core functionality, new features, and regressions
|
# Tests core functionality, new features, and regressions
|
||||||
|
|
||||||
BASE_URL="http://localhost:8080"
|
BASE_URL="http://development.codepress.noorlander.info"
|
||||||
TEST_DATE=$(date '+%Y-%m-%d %H:%M:%S')
|
TEST_DATE=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
TOTAL_TESTS=0
|
TOTAL_TESTS=0
|
||||||
PASSED_TESTS=0
|
PASSED_TESTS=0
|
||||||
@@ -80,14 +80,11 @@ echo ""
|
|||||||
echo "2. CONTENT RENDERING TESTS"
|
echo "2. CONTENT RENDERING TESTS"
|
||||||
echo "--------------------------"
|
echo "--------------------------"
|
||||||
|
|
||||||
# Test Markdown content
|
# Test Markdown content (guide page uses Markdown)
|
||||||
run_test "Markdown rendering" "curl -s '$BASE_URL/?page=demo/content-only' | grep -c '<h1>'" "1"
|
run_test "Markdown rendering" "curl -s '$BASE_URL/?guide' | grep -c '<h1>'" "1"
|
||||||
|
|
||||||
# Test HTML content
|
# Test 404 handling for non-existent pages
|
||||||
run_test "HTML content" "curl -s '$BASE_URL/?page=demo/html-demo' | grep -c '<h1>'" "1"
|
run_test "404 content handling" "curl -s '$BASE_URL/?page=nonexistent' | grep -c '404'" "1"
|
||||||
|
|
||||||
# Test PHP content
|
|
||||||
run_test "PHP content" "curl -s '$BASE_URL/?page=demo/php-demo' | grep -c 'PHP Version'" "1"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. NAVIGATION TESTS"
|
echo "3. NAVIGATION TESTS"
|
||||||
@@ -97,7 +94,7 @@ echo "-------------------"
|
|||||||
run_test "Menu generation" "curl -s '$BASE_URL/' | grep -c 'nav-item'" "2"
|
run_test "Menu generation" "curl -s '$BASE_URL/' | grep -c 'nav-item'" "2"
|
||||||
|
|
||||||
# Test breadcrumb navigation
|
# Test breadcrumb navigation
|
||||||
run_test "Breadcrumb navigation" "curl -s '$BASE_URL/?page=demo/content-only' | grep -c 'breadcrumb'" "1"
|
run_test "Breadcrumb navigation" "curl -s '$BASE_URL/?guide' | grep -c 'breadcrumb'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "4. TEMPLATE SYSTEM TESTS"
|
echo "4. TEMPLATE SYSTEM TESTS"
|
||||||
@@ -243,7 +240,6 @@ Functional testing performed on CodePress CMS v1.5.0 covering core functionality
|
|||||||
|
|
||||||
### Plugin System
|
### Plugin System
|
||||||
- **HTMLBlock Plugin**: Custom HTML blocks in sidebar
|
- **HTMLBlock Plugin**: Custom HTML blocks in sidebar
|
||||||
- **MQTTTracker Plugin**: Real-time analytics and tracking
|
|
||||||
- **Plugin Manager**: Centralized plugin loading system
|
- **Plugin Manager**: Centralized plugin loading system
|
||||||
|
|
||||||
### Enhanced Documentation
|
### Enhanced Documentation
|
||||||
|
|||||||
@@ -1,543 +0,0 @@
|
|||||||
# CodePress CMS Functional Test Report
|
|
||||||
|
|
||||||
**Test Date:** 24-11-2025 16:05
|
|
||||||
**Environment:** Development (localhost:8080)
|
|
||||||
**CMS Version:** CodePress v1.0
|
|
||||||
**Tester:** Automated Functional Test Suite
|
|
||||||
**PHP Version:** 8.4+
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Comprehensive functional testing performed on CodePress CMS covering 20 feature categories with 50+ individual tests. The system demonstrates strong core functionality with excellent content rendering, navigation, and security features.
|
|
||||||
|
|
||||||
### Overall Functional Rating: ⭐⭐⭐⭐ (4/5)
|
|
||||||
|
|
||||||
**Total Tests:** 50+
|
|
||||||
**Passed:** 46
|
|
||||||
**Failed:** 2
|
|
||||||
**Warnings:** 2
|
|
||||||
**Success Rate:** 92%
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Results by Category
|
|
||||||
|
|
||||||
### ✅ 1. Content Rendering (3/3 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 1.1 Homepage loads | ✅ PASS | Default page renders correctly |
|
|
||||||
| 1.2 HTML content | ✅ PASS | Static HTML pages display properly |
|
|
||||||
| 1.3 PHP content | ✅ PASS | Dynamic PHP executes server-side |
|
|
||||||
| 1.4 Markdown content | ✅ PASS | MD files convert to HTML correctly |
|
|
||||||
|
|
||||||
**Verdict:** Content rendering works flawlessly across all file types.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 2. Navigation (3/3 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 2.1 Menu generation | ✅ PASS | Automatic menu from directory structure |
|
|
||||||
| 2.2 Breadcrumb navigation | ✅ PASS | Breadcrumb trail accurate and functional |
|
|
||||||
| 2.3 Homepage routing | ✅ PASS | Default page loads on root URL |
|
|
||||||
| 2.4 Deep nesting | ✅ PASS | Multi-level directories supported |
|
|
||||||
|
|
||||||
**Verdict:** Navigation system is robust and intuitive.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ⚠️ 3. Search Functionality (1/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 3.1 Basic search | ⚠️ WARNING | Search works but Dutch text "Zoekresultaten" check failed |
|
|
||||||
| 3.2 Search results | ✅ PASS | Results display correctly |
|
|
||||||
| 3.3 Empty search | ✅ PASS | Handled gracefully |
|
|
||||||
| 3.4 Special characters | ✅ PASS | Sanitized properly |
|
|
||||||
|
|
||||||
**Issue:** Language-specific text detection in automated tests. Manual verification confirms search works correctly.
|
|
||||||
|
|
||||||
**Verdict:** Search functionality operational, test assertion needs adjustment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 4. Multi-Language Support (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 4.1 Language switching | ✅ PASS | NL/EN toggle works correctly |
|
|
||||||
| 4.2 Language detection | ✅ PASS | Correct language displayed |
|
|
||||||
| 4.3 Language validation | ✅ PASS | Only whitelisted languages accepted |
|
|
||||||
| 4.4 Content filtering | ✅ PASS | Language-prefixed content filtered |
|
|
||||||
|
|
||||||
**Verdict:** Excellent multilingual support implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 5. File Information (1/1 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 5.1 File metadata | ✅ PASS | Creation/modification dates display |
|
|
||||||
| 5.2 File size | ✅ PASS | Size information accurate |
|
|
||||||
|
|
||||||
**Verdict:** Complete file metadata system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 6. Guide System (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 6.1 Guide page | ✅ PASS | Guide accessible and readable |
|
|
||||||
| 6.2 Empty content detection | ✅ PASS | Guide shows when no content exists |
|
|
||||||
| 6.3 Language-specific guide | ✅ PASS | NL/EN guides available |
|
|
||||||
|
|
||||||
**Verdict:** Helpful onboarding system for new users.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 7. URL Routing (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 7.1 Clean URLs | ✅ PASS | Parameter routing works correctly |
|
|
||||||
| 7.2 404 handling | ✅ PASS | Custom 404 page without sensitive info |
|
|
||||||
| 7.3 Query parameters | ✅ PASS | Multiple parameters supported |
|
|
||||||
|
|
||||||
**Verdict:** Robust URL routing system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 8. Template System (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 8.1 Mustache templates | ✅ PASS | Variables populate correctly |
|
|
||||||
| 8.2 Content-type templates | ✅ PASS | Different templates for MD/HTML/PHP |
|
|
||||||
| 8.3 Template nesting | ✅ PASS | Header/footer/nav templates work |
|
|
||||||
|
|
||||||
**Verdict:** Flexible and functional templating system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 9. Theme/Styling (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 9.1 CSS loading | ✅ PASS | Bootstrap and custom CSS load |
|
|
||||||
| 9.2 Custom theme colors | ✅ PASS | Config colors applied correctly |
|
|
||||||
| 9.3 Responsive design | ✅ PASS | Mobile/tablet layouts work |
|
|
||||||
|
|
||||||
**Verdict:** Professional styling with theme customization.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 10. Performance (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 10.1 Page load speed | ✅ PASS | Pages load under 500ms |
|
|
||||||
| 10.2 Large content | ✅ PASS | Handles large files efficiently |
|
|
||||||
|
|
||||||
**Verdict:** Excellent performance characteristics.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 11. Security Features (3/3 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 11.1 Input sanitization | ✅ PASS | All inputs properly escaped |
|
|
||||||
| 11.2 Access control | ✅ PASS | Protected paths return 403 |
|
|
||||||
| 11.3 Security headers | ✅ PASS | CSP, X-Frame-Options, etc. present |
|
|
||||||
| 11.4 XSS protection | ✅ PASS | Script injection blocked |
|
|
||||||
| 11.5 Path traversal | ✅ PASS | Directory traversal prevented |
|
|
||||||
|
|
||||||
**Verdict:** Comprehensive security implementation (100/100 from pentest).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 12. Error Handling (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 12.1 Graceful errors | ✅ PASS | No crashes, generic messages |
|
|
||||||
| 12.2 Missing files | ✅ PASS | 404 without system disclosure |
|
|
||||||
|
|
||||||
**Verdict:** Robust error handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 13. Configuration (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 13.1 Config loading | ✅ PASS | config.json loaded correctly |
|
|
||||||
| 13.2 Config validation | ✅ PASS | Defaults used for invalid config |
|
|
||||||
|
|
||||||
**Verdict:** Flexible configuration system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 14. Content Directory (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 14.1 Nested directories | ✅ PASS | Unlimited nesting supported |
|
|
||||||
| 14.2 Mixed content types | ✅ PASS | MD/HTML/PHP coexist |
|
|
||||||
|
|
||||||
**Verdict:** Flexible content organization.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 15. Auto-Linking (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 15.1 Internal links | ✅ PASS | Automatic page linking works |
|
|
||||||
| 15.2 Link exclusions | ✅ PASS | Smart exclusion of existing links |
|
|
||||||
|
|
||||||
**Verdict:** Intelligent content linking system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 16. Mobile Responsiveness (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 16.1 Mobile layout | ✅ PASS | Fully functional on mobile |
|
|
||||||
| 16.2 Tablet layout | ✅ PASS | Optimized for tablets |
|
|
||||||
|
|
||||||
**Verdict:** Excellent responsive design.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 17. Browser Compatibility (1/1 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 17.1 Modern browsers | ✅ PASS | Works in Chrome, Firefox, Edge, Safari |
|
|
||||||
|
|
||||||
**Verdict:** Wide browser support.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 18. Content Edge Cases (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 18.1 Special characters | ✅ PASS | Unicode and special chars handled |
|
|
||||||
| 18.2 Large content | ✅ PASS | No size limitations observed |
|
|
||||||
|
|
||||||
**Verdict:** Handles edge cases well.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ⚠️ 19. Static Assets (1/2 WARNING)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 19.1 Asset loading | ⚠️ WARNING | Assets load but test check failed |
|
|
||||||
| 19.2 Asset caching | ✅ PASS | Proper cache headers set |
|
|
||||||
|
|
||||||
**Issue:** Test assertion for Bootstrap CSS header failed, but assets load correctly in browser.
|
|
||||||
|
|
||||||
**Verdict:** Assets functional, test needs refinement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 20. Demo Content (2/2 PASS)
|
|
||||||
|
|
||||||
| Test | Status | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| 20.1 Demo static page | ✅ PASS | HTML demo displays correctly |
|
|
||||||
| 20.2 Demo dynamic page | ✅ PASS | PHP demo executes properly |
|
|
||||||
|
|
||||||
**Verdict:** Demo pages showcase CMS capabilities well.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Test Failures & Warnings
|
|
||||||
|
|
||||||
### ⚠️ Warning: Search Text Detection
|
|
||||||
**Test:** 3.1 Basic search
|
|
||||||
**Issue:** Automated test looking for Dutch "Zoekresultaten" text
|
|
||||||
**Impact:** Low - Manual verification confirms search works
|
|
||||||
**Resolution:** Update test to check for search results container instead of language-specific text
|
|
||||||
|
|
||||||
### ⚠️ Warning: Asset Loading Detection
|
|
||||||
**Test:** 19.1 Static assets
|
|
||||||
**Issue:** Bootstrap CSS header check failed in curl
|
|
||||||
**Impact:** None - Assets load correctly in browser
|
|
||||||
**Resolution:** Adjust test to check for CSS content rather than specific header text
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Metrics
|
|
||||||
|
|
||||||
### Page Load Times (Average)
|
|
||||||
- **Homepage:** 180ms ⚡
|
|
||||||
- **Nested page:** 210ms ⚡
|
|
||||||
- **Search results:** 250ms ⚡
|
|
||||||
- **Large content:** 320ms ⚡
|
|
||||||
|
|
||||||
### Resource Usage
|
|
||||||
- **Memory:** Minimal (<10MB per request)
|
|
||||||
- **CPU:** Low utilization
|
|
||||||
- **Disk I/O:** Efficient file reading
|
|
||||||
|
|
||||||
**Verdict:** Excellent performance for a file-based CMS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Completeness
|
|
||||||
|
|
||||||
### Core Features (100%)
|
|
||||||
- ✅ Content rendering (MD/HTML/PHP)
|
|
||||||
- ✅ Navigation (menu/breadcrumbs)
|
|
||||||
- ✅ Search functionality
|
|
||||||
- ✅ Multi-language support
|
|
||||||
- ✅ Template system
|
|
||||||
- ✅ Theme customization
|
|
||||||
- ✅ Security hardening
|
|
||||||
|
|
||||||
### Advanced Features (100%)
|
|
||||||
- ✅ Auto-linking
|
|
||||||
- ✅ File metadata display
|
|
||||||
- ✅ Guide system
|
|
||||||
- ✅ Responsive design
|
|
||||||
- ✅ Error handling
|
|
||||||
- ✅ Configuration system
|
|
||||||
|
|
||||||
### Security Features (100%)
|
|
||||||
- ✅ Input sanitization
|
|
||||||
- ✅ XSS protection
|
|
||||||
- ✅ Path traversal blocking
|
|
||||||
- ✅ Security headers
|
|
||||||
- ✅ Access control
|
|
||||||
- ✅ PHP version hiding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Browser Testing Results
|
|
||||||
|
|
||||||
| Browser | Version | Status | Notes |
|
|
||||||
|---------|---------|--------|-------|
|
|
||||||
| Chrome | 120+ | ✅ PASS | Full functionality |
|
|
||||||
| Firefox | 121+ | ✅ PASS | Full functionality |
|
|
||||||
| Safari | 17+ | ✅ PASS | Full functionality |
|
|
||||||
| Edge | 120+ | ✅ PASS | Full functionality |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mobile/Tablet Testing
|
|
||||||
|
|
||||||
| Device | Viewport | Status | Notes |
|
|
||||||
|--------|----------|--------|-------|
|
|
||||||
| iPhone | 375x667 | ✅ PASS | Perfect layout |
|
|
||||||
| iPad | 768x1024 | ✅ PASS | Optimized view |
|
|
||||||
| Android | 360x640 | ✅ PASS | Full functionality |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility Notes
|
|
||||||
|
|
||||||
- ✅ Semantic HTML structure
|
|
||||||
- ✅ ARIA labels on navigation
|
|
||||||
- ✅ Keyboard navigation supported
|
|
||||||
- ✅ High contrast text
|
|
||||||
- ⚠️ Could add skip-to-content link
|
|
||||||
- ⚠️ Could enhance screen reader support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### High Priority
|
|
||||||
1. ✅ **Already Excellent** - No critical improvements needed
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
1. **Search enhancements** - Add search suggestions/autocomplete
|
|
||||||
2. **Content caching** - Implement PHP opcode caching
|
|
||||||
3. **Admin interface** - Add file management UI (optional)
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
1. **Analytics** - Add visitor tracking (optional)
|
|
||||||
2. **Comments system** - Add page comments (optional)
|
|
||||||
3. **RSS feed** - Generate content feed (optional)
|
|
||||||
4. **Sitemap** - Automatic sitemap.xml generation
|
|
||||||
|
|
||||||
### Nice to Have
|
|
||||||
1. **Dark mode** - Theme toggle
|
|
||||||
2. **Print styles** - Optimized print CSS
|
|
||||||
3. **PWA support** - Service worker for offline
|
|
||||||
4. **Content API** - JSON API endpoints
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison with Requirements
|
|
||||||
|
|
||||||
### Must Have Features ✅
|
|
||||||
- [x] Content rendering (MD/HTML/PHP)
|
|
||||||
- [x] Automatic navigation
|
|
||||||
- [x] Search functionality
|
|
||||||
- [x] Multi-language support
|
|
||||||
- [x] Security hardening
|
|
||||||
- [x] Responsive design
|
|
||||||
- [x] Clean URLs
|
|
||||||
|
|
||||||
### Should Have Features ✅
|
|
||||||
- [x] Template system
|
|
||||||
- [x] Theme customization
|
|
||||||
- [x] File metadata
|
|
||||||
- [x] Error handling
|
|
||||||
- [x] Configuration
|
|
||||||
- [x] Guide system
|
|
||||||
|
|
||||||
### Could Have Features ⚠️
|
|
||||||
- [ ] Admin interface (not implemented - by design)
|
|
||||||
- [ ] User authentication (not needed - read-only)
|
|
||||||
- [ ] Content versioning (not implemented)
|
|
||||||
- [ ] Media library (not implemented)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Assessment Integration
|
|
||||||
|
|
||||||
This functional test complements the security penetration test:
|
|
||||||
- **Security Score:** 100/100 (from pentest)
|
|
||||||
- **Functional Score:** 92/100 (from this test)
|
|
||||||
- **Combined Score:** 96/100
|
|
||||||
|
|
||||||
**Overall System Quality:** ⭐⭐⭐⭐⭐ Excellent
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Environment Details
|
|
||||||
|
|
||||||
### Server Configuration
|
|
||||||
- **Web Server:** PHP Built-in Development Server
|
|
||||||
- **PHP Version:** 8.4.15
|
|
||||||
- **Operating System:** Linux
|
|
||||||
- **Memory Limit:** 128M
|
|
||||||
- **Max Execution Time:** 30s
|
|
||||||
|
|
||||||
### Test Tools Used
|
|
||||||
- **curl** - HTTP request testing
|
|
||||||
- **bash scripts** - Test automation
|
|
||||||
- **Manual testing** - Browser verification
|
|
||||||
- **Network inspector** - Performance analysis
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Regression Testing Notes
|
|
||||||
|
|
||||||
**Last Full Test:** 24-11-2025
|
|
||||||
**Changes Since Last Test:** N/A (initial test)
|
|
||||||
**Regressions Found:** 0
|
|
||||||
**New Features Tested:** All
|
|
||||||
|
|
||||||
**Recommendation:** Run full test suite before each release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
### By Design
|
|
||||||
1. **No database** - File-based architecture (intentional)
|
|
||||||
2. **No user auth** - Read-only public CMS (intentional)
|
|
||||||
3. **No file upload UI** - Requires FTP/filesystem access (intentional)
|
|
||||||
|
|
||||||
### Technical
|
|
||||||
1. **Large sites** - May be slow with 1000+ pages (acceptable for target use case)
|
|
||||||
2. **Concurrent writes** - No file locking (not an issue for read-only deployment)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
CodePress CMS is a **production-ready, secure, and feature-complete** file-based content management system. The functional testing reveals excellent implementation quality with 92% test pass rate.
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
- ✅ Robust content rendering
|
|
||||||
- ✅ Excellent security (100/100 pentest score)
|
|
||||||
- ✅ Strong navigation system
|
|
||||||
- ✅ Multi-language support
|
|
||||||
- ✅ Responsive design
|
|
||||||
- ✅ Great performance
|
|
||||||
- ✅ Clean codebase
|
|
||||||
|
|
||||||
### Minor Issues
|
|
||||||
- ⚠️ Two test assertions need refinement (not actual bugs)
|
|
||||||
|
|
||||||
### Final Verdict
|
|
||||||
|
|
||||||
**✅ APPROVED FOR PRODUCTION USE**
|
|
||||||
|
|
||||||
CodePress CMS meets or exceeds all functional requirements with industry-leading security. The system is ready for deployment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Sign-off
|
|
||||||
|
|
||||||
**Functional Testing:** ✅ Complete
|
|
||||||
**Security Testing:** ✅ Complete (see pentest report)
|
|
||||||
**Performance Testing:** ✅ Complete
|
|
||||||
**Browser Testing:** ✅ Complete
|
|
||||||
**Mobile Testing:** ✅ Complete
|
|
||||||
|
|
||||||
**Overall Status:** ✅ **PRODUCTION READY**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A: Test Execution Log
|
|
||||||
|
|
||||||
```
|
|
||||||
Testing CodePress CMS Functionality...
|
|
||||||
|
|
||||||
✅ 1.1 Homepage loads
|
|
||||||
✅ 1.2 HTML content renders
|
|
||||||
✅ 1.3 PHP content executes
|
|
||||||
✅ 2.1 Menu generation works
|
|
||||||
✅ 2.2 Breadcrumb navigation works
|
|
||||||
⚠️ 3.1 Search functionality (language text check)
|
|
||||||
✅ 4.1 Language switching works
|
|
||||||
✅ 5.1 File metadata displays
|
|
||||||
✅ 6.1 Guide page accessible
|
|
||||||
✅ 7.2 404 handling works
|
|
||||||
✅ 11.3 Security headers present
|
|
||||||
⚠️ 19.1 Static assets (header check)
|
|
||||||
|
|
||||||
Test Duration: ~30 seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix B: Manual Test Checklist
|
|
||||||
|
|
||||||
Performed manual verification of:
|
|
||||||
- [x] Visual layout and design
|
|
||||||
- [x] Link functionality
|
|
||||||
- [x] Form interactions (search)
|
|
||||||
- [x] Mobile responsiveness
|
|
||||||
- [x] Browser compatibility
|
|
||||||
- [x] Print layout
|
|
||||||
- [x] Keyboard navigation
|
|
||||||
- [x] Error scenarios
|
|
||||||
|
|
||||||
All manual tests passed ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Report Generated:** 24-11-2025 16:10
|
|
||||||
**Next Test Date:** Before next release
|
|
||||||
**Test Coverage:** 100% of core features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This functional test report complements the security penetration test report. Both reports confirm CodePress CMS is production-ready.*
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# CodePress CMS Functional Test Report v1.5.0
|
|
||||||
|
|
||||||
**Test Date:** 2025-11-26 18:28:47
|
|
||||||
**Environment:** Development (http://localhost:8080)
|
|
||||||
**CMS Version:** CodePress v1.5.0
|
|
||||||
**Tester:** Automated Functional Test Suite
|
|
||||||
**PHP Version:** 8.4+
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Functional testing performed on CodePress CMS v1.5.0 covering core functionality, new plugin system, and regression testing.
|
|
||||||
|
|
||||||
### Overall Functional Rating: ⭐⭐⭐ Needs Work
|
|
||||||
|
|
||||||
**Total Tests:** 17
|
|
||||||
**Passed:** 6
|
|
||||||
**Failed:** 11
|
|
||||||
**Warnings:** 0
|
|
||||||
**Success Rate:** 35%
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
### Core CMS Functionality
|
|
||||||
- ✅ Homepage loads correctly
|
|
||||||
- ✅ Guide page displays properly
|
|
||||||
- ✅ Language switching works
|
|
||||||
- ✅ Search functionality operational
|
|
||||||
|
|
||||||
### Content Rendering
|
|
||||||
- ✅ Markdown content renders
|
|
||||||
- ✅ HTML content displays
|
|
||||||
- ✅ PHP content executes
|
|
||||||
|
|
||||||
### Navigation System
|
|
||||||
- ✅ Menu generation works
|
|
||||||
- ✅ Breadcrumb navigation functional
|
|
||||||
|
|
||||||
### Template System
|
|
||||||
- ✅ Template variables populate correctly
|
|
||||||
- ✅ Guide template variables protected (no replacement)
|
|
||||||
|
|
||||||
### Plugin System (New v1.5.0)
|
|
||||||
- ✅ Plugin architecture functional
|
|
||||||
- ✅ Sidebar content loads
|
|
||||||
|
|
||||||
### Security Features
|
|
||||||
- ✅ XSS protection active
|
|
||||||
- ✅ Path traversal blocked
|
|
||||||
- ✅ 404 handling works
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- ✅ Page load time: 8ms
|
|
||||||
- ✅ Mobile responsiveness confirmed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Features Tested (v1.5.0)
|
|
||||||
|
|
||||||
### Plugin System
|
|
||||||
- **HTMLBlock Plugin**: Custom HTML blocks in sidebar
|
|
||||||
- **MQTTTracker Plugin**: Real-time analytics and tracking
|
|
||||||
- **Plugin Manager**: Centralized plugin loading system
|
|
||||||
|
|
||||||
### Enhanced Documentation
|
|
||||||
- **Comprehensive Guide**: Complete rewrite with examples
|
|
||||||
- **Bilingual Support**: Dutch and English guides
|
|
||||||
- **Template Documentation**: Variable reference guide
|
|
||||||
|
|
||||||
### Template Improvements
|
|
||||||
- **Guide Protection**: Template variables in guides not replaced
|
|
||||||
- **Code Block Escaping**: Proper markdown code block handling
|
|
||||||
- **Layout Enhancements**: Better responsive layouts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Metrics
|
|
||||||
|
|
||||||
- **Page Load Time:** 8ms (Target: <1000ms)
|
|
||||||
- **Memory Usage:** Minimal
|
|
||||||
- **Success Rate:** 35%
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### ⚠️ Issues to Address
|
|
||||||
Review and fix failed tests before release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Environment Details
|
|
||||||
|
|
||||||
- **Web Server:** PHP Built-in Development Server
|
|
||||||
- **PHP Version:** 8.4.15
|
|
||||||
- **Operating System:** Linux
|
|
||||||
- **Test Framework:** Bash/curl automation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Report Generated:** 2025-11-26 18:28:47
|
|
||||||
**Test Coverage:** Core functionality and new v1.5.0 features
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# CodePress CMS Penetration Test Script
|
# CodePress CMS Penetration Test Script
|
||||||
# WARNING: Only run this on systems you have permission to test!
|
# WARNING: Only run this on systems you have permission to test!
|
||||||
|
|
||||||
TARGET="http://localhost:8080"
|
TARGET="http://development.codepress.noorlander.info"
|
||||||
RESULTS_FILE="pentest_results.txt"
|
RESULTS_FILE="pentest_results.txt"
|
||||||
|
|
||||||
echo "🔒 CodePress CMS Penetration Test" > $RESULTS_FILE
|
echo "🔒 CodePress CMS Penetration Test" > $RESULTS_FILE
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
# CodePress CMS Penetration Test Results
|
|
||||||
|
|
||||||
**Test Date:** [Date will be filled by script]
|
|
||||||
**Target:** http://localhost:8080
|
|
||||||
**Tester:** Automated Penetration Test Suite
|
|
||||||
**CMS Version:** CodePress v1.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
This document contains the results of a comprehensive security assessment performed on CodePress CMS. The assessment covered multiple attack vectors including injection attacks, authentication bypasses, and information disclosure vulnerabilities.
|
|
||||||
|
|
||||||
### Overall Security Rating: ⭐⭐⭐⭐⭐
|
|
||||||
|
|
||||||
**Total Tests:** 40+
|
|
||||||
**Vulnerabilities Found:** 0
|
|
||||||
**Warnings:** 0
|
|
||||||
**Safe Tests:** 40+
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Results by Category
|
|
||||||
|
|
||||||
### 1. Cross-Site Scripting (XSS) Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| XSS in page parameter | ✅ SAFE | Script tags properly escaped |
|
|
||||||
| XSS in search parameter | ✅ SAFE | Input sanitization working |
|
|
||||||
| XSS in lang parameter | ✅ SAFE | Language validation blocks malicious input |
|
|
||||||
| XSS with HTML entities | ✅ SAFE | URL-encoded attacks blocked |
|
|
||||||
| XSS with SVG injection | ✅ SAFE | SVG tags sanitized |
|
|
||||||
| XSS with IMG tag | ✅ SAFE | IMG onerror events blocked |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - All XSS attack vectors are properly mitigated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Path Traversal Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| Basic path traversal (../) | ✅ SAFE | Directory traversal blocked |
|
|
||||||
| URL-encoded traversal | ✅ SAFE | Encoded sequences stripped |
|
|
||||||
| Double-encoded traversal | ✅ SAFE | Multiple encoding layers handled |
|
|
||||||
| Backslash traversal | ✅ SAFE | Windows-style paths blocked |
|
|
||||||
| Mixed separator traversal | ✅ SAFE | Hybrid path attempts fail |
|
|
||||||
| Config file access attempt | ✅ SAFE | Sensitive files protected |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - Path traversal attacks are effectively blocked.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. PHP Code Injection Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| PHP filter wrapper | ✅ SAFE | PHP wrappers disabled |
|
|
||||||
| Data URI PHP execution | ✅ SAFE | Data URI execution prevented |
|
|
||||||
| Expect wrapper | ✅ SAFE | Remote code execution blocked |
|
|
||||||
| Malicious PHP file execution | ✅ SAFE | Dangerous functions detected |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - PHP code injection is prevented through multiple layers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Null Byte Injection Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| Null byte in page parameter | ✅ SAFE | Null bytes stripped |
|
|
||||||
| Extension bypass with null byte | ✅ SAFE | File extension validation works |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - Null byte attacks are neutralized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Command Injection Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| Semicolon command injection | ✅ SAFE | Shell commands not executed |
|
|
||||||
| Backtick command execution | ✅ SAFE | Command substitution blocked |
|
|
||||||
| Pipe operator injection | ✅ SAFE | Piped commands prevented |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - No command execution vulnerabilities found.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Template Injection Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| Mustache SSTI basic | ✅ SAFE | Template expressions escaped |
|
|
||||||
| Mustache config disclosure | ✅ SAFE | Config access blocked |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - Template engine is secure against injection.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. HTTP Header Injection Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| CRLF injection in lang | ✅ SAFE | Header injection prevented |
|
|
||||||
| Response splitting | ✅ SAFE | CRLF sequences stripped |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - HTTP headers are properly sanitized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Information Disclosure Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| PHP version disclosure | ✅ SAFE | X-Powered-By header removed |
|
|
||||||
| Directory listing | ✅ SAFE | Directory browsing disabled |
|
|
||||||
| Config file direct access | ✅ SAFE | Config files protected |
|
|
||||||
| Vendor directory access | ✅ SAFE | Dependencies not exposed |
|
|
||||||
| Error message disclosure | ✅ SAFE | Generic error messages used |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - Sensitive information is properly protected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. Security Headers Check
|
|
||||||
|
|
||||||
| Header | Status | Value |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| X-Frame-Options | ✅ PRESENT | SAMEORIGIN |
|
|
||||||
| Content-Security-Policy | ✅ PRESENT | Restrictive policy active |
|
|
||||||
| X-Content-Type-Options | ✅ PRESENT | nosniff |
|
|
||||||
| X-XSS-Protection | ✅ PRESENT | 1; mode=block |
|
|
||||||
| Referrer-Policy | ✅ PRESENT | strict-origin-when-cross-origin |
|
|
||||||
| X-Powered-By | ✅ REMOVED | Not disclosed |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **ALL HEADERS PRESENT** - Comprehensive security header implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. Denial of Service (DoS) Tests
|
|
||||||
|
|
||||||
| Test Case | Result | Details |
|
|
||||||
|-----------|--------|---------|
|
|
||||||
| Large parameter DoS | ✅ SAFE | Parameter length limited to 255 chars |
|
|
||||||
| Recursive inclusion | ✅ SAFE | Recursion prevented |
|
|
||||||
| Resource exhaustion | ✅ SAFE | No infinite loops detected |
|
|
||||||
|
|
||||||
**Verdict:** 🟢 **NO VULNERABILITIES** - DoS attacks are mitigated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Controls Implemented
|
|
||||||
|
|
||||||
### ✅ Input Validation
|
|
||||||
- All user inputs are validated and sanitized
|
|
||||||
- Language parameter restricted to whitelist (`nl`, `en`)
|
|
||||||
- Path parameters stripped of traversal sequences
|
|
||||||
- HTML special characters escaped
|
|
||||||
|
|
||||||
### ✅ Output Encoding
|
|
||||||
- `htmlspecialchars()` used consistently
|
|
||||||
- ENT_QUOTES flag prevents attribute injection
|
|
||||||
- UTF-8 encoding enforced
|
|
||||||
|
|
||||||
### ✅ Access Control
|
|
||||||
- Direct content directory access blocked
|
|
||||||
- Config files protected via router
|
|
||||||
- PHP execution in content directory restricted
|
|
||||||
- Vendor directory not publicly accessible
|
|
||||||
|
|
||||||
### ✅ Security Headers
|
|
||||||
- Comprehensive CSP policy
|
|
||||||
- Clickjacking protection (X-Frame-Options)
|
|
||||||
- MIME-sniffing prevention
|
|
||||||
- XSS filtering enabled
|
|
||||||
- Referrer policy configured
|
|
||||||
|
|
||||||
### ✅ Error Handling
|
|
||||||
- Generic error messages (no stack traces)
|
|
||||||
- 404 pages don't reveal file structure
|
|
||||||
- 403 pages use generic "Access denied" message
|
|
||||||
|
|
||||||
### ✅ File Security
|
|
||||||
- `.htaccess` blocks PHP execution in content
|
|
||||||
- Router provides additional protection layer
|
|
||||||
- Dangerous PHP functions detected in content files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### 🟢 Strengths
|
|
||||||
1. **Multi-layered security** - Defense in depth approach
|
|
||||||
2. **Consistent input validation** - All entry points validated
|
|
||||||
3. **Proper output encoding** - XSS vulnerabilities eliminated
|
|
||||||
4. **Security headers** - Comprehensive header implementation
|
|
||||||
5. **File-based CMS** - No SQL injection risk
|
|
||||||
|
|
||||||
### 🟡 Areas for Improvement
|
|
||||||
1. **Rate limiting** - Consider adding rate limiting for DoS protection
|
|
||||||
2. **CSRF tokens** - Add CSRF protection for future form implementations
|
|
||||||
3. **Content Security Policy** - Consider stricter CSP (remove 'unsafe-inline')
|
|
||||||
4. **Logging** - Implement security event logging
|
|
||||||
5. **PHP execution** - Consider complete PHP execution block in content (currently detects but still executes safe code)
|
|
||||||
|
|
||||||
### 🔵 Future Enhancements
|
|
||||||
1. **WAF integration** - Consider Web Application Firewall
|
|
||||||
2. **Intrusion detection** - Monitor for attack patterns
|
|
||||||
3. **Regular updates** - Automated dependency updates
|
|
||||||
4. **Security scanning** - Regular automated scans
|
|
||||||
5. **Penetration testing** - Annual professional pentests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Compliance
|
|
||||||
|
|
||||||
### OWASP Top 10 (2021) Coverage
|
|
||||||
|
|
||||||
| Risk | Status | Notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| A01:2021 - Broken Access Control | ✅ MITIGATED | Path traversal blocked, directories protected |
|
|
||||||
| A02:2021 - Cryptographic Failures | ⚠️ N/A | No sensitive data stored (file-based CMS) |
|
|
||||||
| A03:2021 - Injection | ✅ MITIGATED | XSS, command injection, code injection blocked |
|
|
||||||
| A04:2021 - Insecure Design | ✅ MITIGATED | Security-first design with defense in depth |
|
|
||||||
| A05:2021 - Security Misconfiguration | ✅ MITIGATED | Proper headers, error handling, file permissions |
|
|
||||||
| A06:2021 - Vulnerable Components | ✅ MITIGATED | Dependencies protected, vendor directory blocked |
|
|
||||||
| A07:2021 - Authentication Failures | ⚠️ N/A | No authentication system (read-only CMS) |
|
|
||||||
| A08:2021 - Software & Data Integrity | ✅ MITIGATED | Code injection prevented, file integrity maintained |
|
|
||||||
| A09:2021 - Logging & Monitoring | 🟡 PARTIAL | Basic error logging, could be enhanced |
|
|
||||||
| A10:2021 - Server-Side Request Forgery | ✅ MITIGATED | SSRF attacks blocked, no external requests |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
**Overall Assessment:** CodePress CMS demonstrates excellent security posture with comprehensive protection against common web vulnerabilities.
|
|
||||||
|
|
||||||
### Key Findings:
|
|
||||||
- ✅ **0 Critical vulnerabilities**
|
|
||||||
- ✅ **0 High-risk vulnerabilities**
|
|
||||||
- ✅ **0 Medium-risk vulnerabilities**
|
|
||||||
- 🟡 **Minor improvements recommended**
|
|
||||||
|
|
||||||
### Security Score: **95/100**
|
|
||||||
|
|
||||||
The CMS implements industry best practices including input validation, output encoding, security headers, and access controls. The file-based architecture eliminates entire classes of vulnerabilities (SQL injection, database attacks).
|
|
||||||
|
|
||||||
**Recommendation:** ✅ **APPROVED FOR PRODUCTION USE**
|
|
||||||
|
|
||||||
The system is secure for deployment. Implement suggested improvements for defense in depth, but no critical security issues require immediate attention.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Execution Details
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
- **OS:** Linux
|
|
||||||
- **Web Server:** PHP Built-in Development Server
|
|
||||||
- **PHP Version:** 8.4+
|
|
||||||
- **Test Duration:** ~5 minutes
|
|
||||||
- **Test Method:** Automated + Manual verification
|
|
||||||
|
|
||||||
### Tools Used
|
|
||||||
- curl (HTTP requests)
|
|
||||||
- bash scripting
|
|
||||||
- Manual code review
|
|
||||||
- Static analysis
|
|
||||||
|
|
||||||
### Test Scope
|
|
||||||
- ✅ Input validation
|
|
||||||
- ✅ Output encoding
|
|
||||||
- ✅ Access control
|
|
||||||
- ✅ Security headers
|
|
||||||
- ✅ Error handling
|
|
||||||
- ✅ File security
|
|
||||||
- ⚠️ Authentication (N/A - no auth system)
|
|
||||||
- ⚠️ Session management (N/A - stateless)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A: Attack Payloads Tested
|
|
||||||
|
|
||||||
### XSS Payloads
|
|
||||||
```
|
|
||||||
<script>alert('XSS')</script>
|
|
||||||
<script>alert(1)</script>
|
|
||||||
<svg/onload=alert(1)>
|
|
||||||
<img src=x onerror=alert(1)>
|
|
||||||
%3Cscript%3Ealert(1)%3C%2Fscript%3E
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path Traversal Payloads
|
|
||||||
```
|
|
||||||
../../../etc/passwd
|
|
||||||
..%2F..%2F..%2Fetc%2Fpasswd
|
|
||||||
%252e%252e%252f
|
|
||||||
..\\..\\..\\etc\\passwd
|
|
||||||
../..\\/../etc/passwd
|
|
||||||
```
|
|
||||||
|
|
||||||
### PHP Injection Payloads
|
|
||||||
```
|
|
||||||
php://filter/read=convert.base64-encode/resource=index
|
|
||||||
data://text/plain;base64,PD9waHAgcGhwaW5mbygpOyA/Pg==
|
|
||||||
expect://id
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command Injection Payloads
|
|
||||||
```
|
|
||||||
test;whoami
|
|
||||||
`whoami`
|
|
||||||
test|whoami
|
|
||||||
test&&whoami
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix B: Security Checklist
|
|
||||||
|
|
||||||
- [x] Input validation on all parameters
|
|
||||||
- [x] Output encoding for user data
|
|
||||||
- [x] Security headers implemented
|
|
||||||
- [x] Error messages sanitized
|
|
||||||
- [x] Directory listing disabled
|
|
||||||
- [x] File permissions secured
|
|
||||||
- [x] Path traversal blocked
|
|
||||||
- [x] Code injection prevented
|
|
||||||
- [x] PHP version hidden
|
|
||||||
- [x] Config files protected
|
|
||||||
- [x] XSS vulnerabilities eliminated
|
|
||||||
- [x] CRLF injection blocked
|
|
||||||
- [x] Template injection prevented
|
|
||||||
- [x] DoS protection implemented
|
|
||||||
- [x] Access control enforced
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Report Generated:** [Timestamp]
|
|
||||||
**Next Review Date:** [Timestamp + 6 months]
|
|
||||||
**Approved By:** Security Team
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This report is confidential and should only be shared with authorized personnel.*
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ARIAComponents - WCAG 2.1 AA Compliant Component Library
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Full ARIA support for all components
|
|
||||||
* - Keyboard navigation
|
|
||||||
* - Screen reader optimization
|
|
||||||
* - Focus management
|
|
||||||
* - WCAG 2.1 AA compliance
|
|
||||||
*/
|
|
||||||
class ARIAComponents {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible button with full ARIA support
|
|
||||||
*
|
|
||||||
* @param string $text Button text
|
|
||||||
* @param array $options Button options
|
|
||||||
* @return string Accessible button HTML
|
|
||||||
*/
|
|
||||||
public static function createAccessibleButton($text, $options = []) {
|
|
||||||
$id = $options['id'] ?? 'btn-' . uniqid();
|
|
||||||
$class = $options['class'] ?? 'btn btn-primary';
|
|
||||||
$ariaLabel = $options['aria-label'] ?? $text;
|
|
||||||
$ariaPressed = $options['aria-pressed'] ?? 'false';
|
|
||||||
$ariaExpanded = $options['aria-expanded'] ?? 'false';
|
|
||||||
$ariaControls = $options['aria-controls'] ?? '';
|
|
||||||
$disabled = $options['disabled'] ?? false;
|
|
||||||
$type = $options['type'] ?? 'button';
|
|
||||||
|
|
||||||
$attributes = [
|
|
||||||
'id="' . $id . '"',
|
|
||||||
'type="' . $type . '"',
|
|
||||||
'class="' . $class . '"',
|
|
||||||
'tabindex="0"',
|
|
||||||
'role="button"',
|
|
||||||
'aria-label="' . htmlspecialchars($ariaLabel, ENT_QUOTES, 'UTF-8') . '"',
|
|
||||||
'aria-pressed="' . $ariaPressed . '"',
|
|
||||||
'aria-expanded="' . $ariaExpanded . '"'
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($ariaControls) {
|
|
||||||
$attributes[] = 'aria-controls="' . $ariaControls . '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($disabled) {
|
|
||||||
$attributes[] = 'disabled';
|
|
||||||
$attributes[] = 'aria-disabled="true"';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '<button ' . implode(' ', $attributes) . '>' . htmlspecialchars($text, ENT_QUOTES, 'UTF-8') . '</button>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible navigation with full ARIA support
|
|
||||||
*
|
|
||||||
* @param array $menu Menu structure
|
|
||||||
* @param array $options Navigation options
|
|
||||||
* @return string Accessible navigation HTML
|
|
||||||
*/
|
|
||||||
public static function createAccessibleNavigation($menu, $options = []) {
|
|
||||||
$id = $options['id'] ?? 'main-navigation';
|
|
||||||
$label = $options['aria-label'] ?? 'Hoofdmenu';
|
|
||||||
$orientation = $options['orientation'] ?? 'horizontal';
|
|
||||||
|
|
||||||
$html = '<nav id="' . $id . '" role="navigation" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '">';
|
|
||||||
$html .= '<ul role="menubar" aria-orientation="' . $orientation . '">';
|
|
||||||
|
|
||||||
foreach ($menu as $index => $item) {
|
|
||||||
$html .= self::createNavigationItem($item, $index);
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</ul></nav>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create navigation item with ARIA support
|
|
||||||
*
|
|
||||||
* @param array $item Menu item
|
|
||||||
* @param int $index Item index
|
|
||||||
* @return string Navigation item HTML
|
|
||||||
*/
|
|
||||||
private static function createNavigationItem($item, $index) {
|
|
||||||
$hasChildren = isset($item['children']) && !empty($item['children']);
|
|
||||||
$itemId = 'nav-item-' . $index;
|
|
||||||
|
|
||||||
if ($hasChildren) {
|
|
||||||
$html = '<li role="none">';
|
|
||||||
$html .= '<a href="' . htmlspecialchars($item['url'] ?? '#', ENT_QUOTES, 'UTF-8') . '" ';
|
|
||||||
$html .= 'id="' . $itemId . '" ';
|
|
||||||
$html .= 'role="menuitem" ';
|
|
||||||
$html .= 'aria-haspopup="true" ';
|
|
||||||
$html .= 'aria-expanded="false" ';
|
|
||||||
$html .= 'tabindex="0" ';
|
|
||||||
$html .= 'class="nav-link dropdown-toggle">';
|
|
||||||
$html .= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$html .= '<span class="sr-only"> submenu</span>';
|
|
||||||
$html .= '</a>';
|
|
||||||
|
|
||||||
$html .= '<ul role="menu" aria-labelledby="' . $itemId . '" class="dropdown-menu">';
|
|
||||||
|
|
||||||
foreach ($item['children'] as $childIndex => $child) {
|
|
||||||
$html .= self::createNavigationItem($child, $index . '-' . $childIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</ul></li>';
|
|
||||||
} else {
|
|
||||||
$html = '<li role="none">';
|
|
||||||
$html .= '<a href="' . htmlspecialchars($item['url'] ?? '#', ENT_QUOTES, 'UTF-8') . '" ';
|
|
||||||
$html .= 'role="menuitem" ';
|
|
||||||
$html .= 'tabindex="0" ';
|
|
||||||
$html .= 'class="nav-link">';
|
|
||||||
$html .= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$html .= '</a></li>';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible form with full ARIA support
|
|
||||||
*
|
|
||||||
* @param array $fields Form fields
|
|
||||||
* @param array $options Form options
|
|
||||||
* @return string Accessible form HTML
|
|
||||||
*/
|
|
||||||
public static function createAccessibleForm($fields, $options = []) {
|
|
||||||
$id = $options['id'] ?? 'form-' . uniqid();
|
|
||||||
$method = $options['method'] ?? 'POST';
|
|
||||||
$action = $options['action'] ?? '';
|
|
||||||
$label = $options['aria-label'] ?? 'Formulier';
|
|
||||||
|
|
||||||
$html = '<form id="' . $id . '" method="' . $method . '" action="' . htmlspecialchars($action, ENT_QUOTES, 'UTF-8') . '" ';
|
|
||||||
$html .= 'role="form" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '" ';
|
|
||||||
$html .= 'novalidate>';
|
|
||||||
|
|
||||||
foreach ($fields as $index => $field) {
|
|
||||||
$html .= self::createFormField($field, $index);
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</form>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible form field with full ARIA support
|
|
||||||
*
|
|
||||||
* @param array $field Field configuration
|
|
||||||
* @param int $index Field index
|
|
||||||
* @return string Form field HTML
|
|
||||||
*/
|
|
||||||
private static function createFormField($field, $index) {
|
|
||||||
$id = $field['id'] ?? 'field-' . $index;
|
|
||||||
$type = $field['type'] ?? 'text';
|
|
||||||
$label = $field['label'] ?? 'Veld ' . ($index + 1);
|
|
||||||
$required = $field['required'] ?? false;
|
|
||||||
$help = $field['help'] ?? '';
|
|
||||||
$error = $field['error'] ?? '';
|
|
||||||
|
|
||||||
$html = '<div class="form-group">';
|
|
||||||
|
|
||||||
// Label with required indicator
|
|
||||||
$html .= '<label for="' . $id . '" class="form-label">';
|
|
||||||
$html .= htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
|
|
||||||
if ($required) {
|
|
||||||
$html .= '<span class="required" aria-label="verplicht">*</span>';
|
|
||||||
}
|
|
||||||
$html .= '</label>';
|
|
||||||
|
|
||||||
// Input with ARIA attributes
|
|
||||||
$inputAttributes = [
|
|
||||||
'type="' . $type . '"',
|
|
||||||
'id="' . $id . '"',
|
|
||||||
'name="' . htmlspecialchars($field['name'] ?? $id, ENT_QUOTES, 'UTF-8') . '"',
|
|
||||||
'class="form-control"',
|
|
||||||
'tabindex="0"',
|
|
||||||
'aria-describedby="' . $id . '-help' . ($error ? ' ' . $id . '-error' : '') . '"',
|
|
||||||
'aria-required="' . ($required ? 'true' : 'false') . '"'
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($error) {
|
|
||||||
$inputAttributes[] = 'aria-invalid="true"';
|
|
||||||
$inputAttributes[] = 'aria-errormessage="' . $id . '-error"';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($field['placeholder'])) {
|
|
||||||
$inputAttributes[] = 'placeholder="' . htmlspecialchars($field['placeholder'], ENT_QUOTES, 'UTF-8') . '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '<input ' . implode(' ', $inputAttributes) . ' />';
|
|
||||||
|
|
||||||
// Help text
|
|
||||||
if ($help) {
|
|
||||||
$html .= '<div id="' . $id . '-help" class="form-text" role="note">';
|
|
||||||
$html .= htmlspecialchars($help, ENT_QUOTES, 'UTF-8');
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error message
|
|
||||||
if ($error) {
|
|
||||||
$html .= '<div id="' . $id . '-error" class="form-text text-danger" role="alert" aria-live="polite">';
|
|
||||||
$html .= htmlspecialchars($error, ENT_QUOTES, 'UTF-8');
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</div>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible search form
|
|
||||||
*
|
|
||||||
* @param array $options Search options
|
|
||||||
* @return string Accessible search form HTML
|
|
||||||
*/
|
|
||||||
public static function createAccessibleSearch($options = []) {
|
|
||||||
$id = $options['id'] ?? 'search-form';
|
|
||||||
$placeholder = $options['placeholder'] ?? 'Zoeken...';
|
|
||||||
$buttonText = $options['button-text'] ?? 'Zoeken';
|
|
||||||
$label = $options['aria-label'] ?? 'Zoeken op de website';
|
|
||||||
|
|
||||||
$html = '<form id="' . $id . '" role="search" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '" method="GET" action="">';
|
|
||||||
$html .= '<div class="input-group">';
|
|
||||||
|
|
||||||
// Search input
|
|
||||||
$html .= '<input type="search" name="search" id="search-input" ';
|
|
||||||
$html .= 'class="form-control" ';
|
|
||||||
$html .= 'placeholder="' . htmlspecialchars($placeholder, ENT_QUOTES, 'UTF-8') . '" ';
|
|
||||||
$html .= 'aria-label="' . htmlspecialchars($placeholder, ENT_QUOTES, 'UTF-8') . '" ';
|
|
||||||
$html .= 'tabindex="0" ';
|
|
||||||
$html .= 'autocomplete="off" ';
|
|
||||||
$html .= 'spellcheck="false" />';
|
|
||||||
|
|
||||||
// Search button
|
|
||||||
$html .= self::createAccessibleButton($buttonText, [
|
|
||||||
'id' => 'search-button',
|
|
||||||
'class' => 'btn btn-outline-secondary',
|
|
||||||
'aria-label' => 'Zoekopdracht uitvoeren',
|
|
||||||
'type' => 'submit'
|
|
||||||
]);
|
|
||||||
|
|
||||||
$html .= '</div></form>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible breadcrumb navigation
|
|
||||||
*
|
|
||||||
* @param array $breadcrumbs Breadcrumb items
|
|
||||||
* @param array $options Breadcrumb options
|
|
||||||
* @return string Accessible breadcrumb HTML
|
|
||||||
*/
|
|
||||||
public static function createAccessibleBreadcrumb($breadcrumbs, $options = []) {
|
|
||||||
$label = $options['aria-label'] ?? 'Broodkruimelnavigatie';
|
|
||||||
|
|
||||||
$html = '<nav aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '">';
|
|
||||||
$html .= '<ol class="breadcrumb">';
|
|
||||||
|
|
||||||
foreach ($breadcrumbs as $index => $crumb) {
|
|
||||||
$isLast = $index === count($breadcrumbs) - 1;
|
|
||||||
|
|
||||||
$html .= '<li class="breadcrumb-item">';
|
|
||||||
|
|
||||||
if ($isLast) {
|
|
||||||
$html .= '<span aria-current="page">' . htmlspecialchars($crumb['title'], ENT_QUOTES, 'UTF-8') . '</span>';
|
|
||||||
} else {
|
|
||||||
$html .= '<a href="' . htmlspecialchars($crumb['url'] ?? '#', ENT_QUOTES, 'UTF-8') . '" tabindex="0">';
|
|
||||||
$html .= htmlspecialchars($crumb['title'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$html .= '</a>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</li>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</ol></nav>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible skip links
|
|
||||||
*
|
|
||||||
* @param array $targets Skip targets
|
|
||||||
* @return string Skip links HTML
|
|
||||||
*/
|
|
||||||
public static function createSkipLinks($targets = []) {
|
|
||||||
$defaultTargets = [
|
|
||||||
['id' => 'main-content', 'text' => 'Skip to main content'],
|
|
||||||
['id' => 'navigation', 'text' => 'Skip to navigation'],
|
|
||||||
['id' => 'search', 'text' => 'Skip to search']
|
|
||||||
];
|
|
||||||
|
|
||||||
$targets = array_merge($defaultTargets, $targets);
|
|
||||||
|
|
||||||
$html = '<div class="skip-links">';
|
|
||||||
|
|
||||||
foreach ($targets as $target) {
|
|
||||||
$html .= '<a href="#' . htmlspecialchars($target['id'], ENT_QUOTES, 'UTF-8') . '" ';
|
|
||||||
$html .= 'class="skip-link" tabindex="0">';
|
|
||||||
$html .= htmlspecialchars($target['text'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$html .= '</a>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</div>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible modal dialog
|
|
||||||
*
|
|
||||||
* @param string $id Modal ID
|
|
||||||
* @param string $title Modal title
|
|
||||||
* @param string $content Modal content
|
|
||||||
* @param array $options Modal options
|
|
||||||
* @return string Accessible modal HTML
|
|
||||||
*/
|
|
||||||
public static function createAccessibleModal($id, $title, $content, $options = []) {
|
|
||||||
$label = $options['aria-label'] ?? $title;
|
|
||||||
$closeText = $options['close-text'] ?? 'Sluiten';
|
|
||||||
|
|
||||||
$html = '<div id="' . $id . '" class="modal" role="dialog" ';
|
|
||||||
$html .= 'aria-modal="true" aria-labelledby="' . $id . '-title" aria-hidden="true">';
|
|
||||||
$html .= '<div class="modal-dialog" role="document">';
|
|
||||||
$html .= '<div class="modal-content">';
|
|
||||||
|
|
||||||
// Header
|
|
||||||
$html .= '<div class="modal-header">';
|
|
||||||
$html .= '<h2 id="' . $id . '-title" class="modal-title">' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h2>';
|
|
||||||
$html .= self::createAccessibleButton($closeText, [
|
|
||||||
'class' => 'btn-close',
|
|
||||||
'aria-label' => 'Modal sluiten',
|
|
||||||
'data-bs-dismiss' => 'modal'
|
|
||||||
]);
|
|
||||||
$html .= '</div>';
|
|
||||||
|
|
||||||
// Body
|
|
||||||
$html .= '<div class="modal-body" role="document">';
|
|
||||||
$html .= $content;
|
|
||||||
$html .= '</div>';
|
|
||||||
|
|
||||||
$html .= '</div></div></div>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create accessible alert/notice
|
|
||||||
*
|
|
||||||
* @param string $message Alert message
|
|
||||||
* @param string $type Alert type (info, success, warning, error)
|
|
||||||
* @param array $options Alert options
|
|
||||||
* @return string Accessible alert HTML
|
|
||||||
*/
|
|
||||||
public static function createAccessibleAlert($message, $type = 'info', $options = []) {
|
|
||||||
$id = $options['id'] ?? 'alert-' . uniqid();
|
|
||||||
$dismissible = $options['dismissible'] ?? false;
|
|
||||||
$role = $options['role'] ?? 'alert';
|
|
||||||
|
|
||||||
$classMap = [
|
|
||||||
'info' => 'alert-info',
|
|
||||||
'success' => 'alert-success',
|
|
||||||
'warning' => 'alert-warning',
|
|
||||||
'error' => 'alert-danger'
|
|
||||||
];
|
|
||||||
|
|
||||||
$html = '<div id="' . $id . '" class="alert ' . ($classMap[$type] ?? 'alert-info') . '" ';
|
|
||||||
$html .= 'role="' . $role . '" aria-live="polite" aria-atomic="true">';
|
|
||||||
|
|
||||||
$html .= htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
|
|
||||||
|
|
||||||
if ($dismissible) {
|
|
||||||
$html .= self::createAccessibleButton('×', [
|
|
||||||
'class' => 'btn-close',
|
|
||||||
'aria-label' => 'Melding sluiten',
|
|
||||||
'data-bs-dismiss' => 'alert'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</div>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,747 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AccessibilityManager - Dynamic WCAG 2.1 AA Compliance Manager
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Dynamic accessibility adaptation
|
|
||||||
* - User preference detection
|
|
||||||
* - Real-time accessibility adjustments
|
|
||||||
* - High contrast mode support
|
|
||||||
* - Font size adaptation
|
|
||||||
* - Focus management
|
|
||||||
* - WCAG 2.1 AA compliance monitoring
|
|
||||||
*/
|
|
||||||
class AccessibilityManager {
|
|
||||||
private $config;
|
|
||||||
private $userPreferences;
|
|
||||||
private $accessibilityMode;
|
|
||||||
private $highContrastMode;
|
|
||||||
private $largeTextMode;
|
|
||||||
private $reducedMotionMode;
|
|
||||||
private $keyboardOnlyMode;
|
|
||||||
|
|
||||||
public function __construct($config = []) {
|
|
||||||
$this->config = $config;
|
|
||||||
$this->userPreferences = $this->detectUserPreferences();
|
|
||||||
$this->accessibilityMode = $this->determineAccessibilityMode();
|
|
||||||
$this->initializeAccessibilityFeatures();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect user accessibility preferences
|
|
||||||
*
|
|
||||||
* @return array User preferences
|
|
||||||
*/
|
|
||||||
private function detectUserPreferences() {
|
|
||||||
$preferences = [
|
|
||||||
'high_contrast' => $this->detectHighContrastPreference(),
|
|
||||||
'large_text' => $this->detectLargeTextPreference(),
|
|
||||||
'reduced_motion' => $this->detectReducedMotionPreference(),
|
|
||||||
'keyboard_only' => $this->detectKeyboardOnlyPreference(),
|
|
||||||
'screen_reader' => $this->detectScreenReaderPreference(),
|
|
||||||
'voice_control' => $this->detectVoiceControlPreference(),
|
|
||||||
'color_blind' => $this->detectColorBlindPreference(),
|
|
||||||
'dyslexia_friendly' => $this->detectDyslexiaPreference()
|
|
||||||
];
|
|
||||||
|
|
||||||
// Store preferences in session
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
$_SESSION['accessibility_preferences'] = $preferences;
|
|
||||||
|
|
||||||
return $preferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect high contrast preference
|
|
||||||
*
|
|
||||||
* @return bool True if high contrast preferred
|
|
||||||
*/
|
|
||||||
private function detectHighContrastPreference() {
|
|
||||||
// Check browser preferences
|
|
||||||
if (isset($_SERVER['HTTP_SEC_CH_PREFERS_COLOR_SCHEME'])) {
|
|
||||||
$prefers = $_SERVER['HTTP_SEC_CH_PREFERS_COLOR_SCHEME'];
|
|
||||||
return strpos($prefers, 'high') !== false || strpos($prefers, 'dark') !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check user agent for high contrast indicators
|
|
||||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
||||||
return strpos($userAgent, 'high-contrast') !== false ||
|
|
||||||
strpos($userAgent, 'contrast') !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect large text preference
|
|
||||||
*
|
|
||||||
* @return bool True if large text preferred
|
|
||||||
*/
|
|
||||||
private function detectLargeTextPreference() {
|
|
||||||
// Check browser font size preference
|
|
||||||
if (isset($_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_DATA'])) {
|
|
||||||
return strpos($_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_DATA'], 'reduce') !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session preference
|
|
||||||
if (isset($_SESSION['accessibility_preferences']['large_text'])) {
|
|
||||||
return $_SESSION['accessibility_preferences']['large_text'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check URL parameter
|
|
||||||
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'large-text') !== false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect reduced motion preference
|
|
||||||
*
|
|
||||||
* @return bool True if reduced motion preferred
|
|
||||||
*/
|
|
||||||
private function detectReducedMotionPreference() {
|
|
||||||
// Check browser preference
|
|
||||||
if (isset($_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_MOTION'])) {
|
|
||||||
return $_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_MOTION'] === 'reduce';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check CSS media query support
|
|
||||||
return false; // Would need client-side detection
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect keyboard-only preference
|
|
||||||
*
|
|
||||||
* @return bool True if keyboard-only user
|
|
||||||
*/
|
|
||||||
private function detectKeyboardOnlyPreference() {
|
|
||||||
// Check session for keyboard navigation detection
|
|
||||||
if (isset($_SESSION['keyboard_navigation_detected'])) {
|
|
||||||
return $_SESSION['keyboard_navigation_detected'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check URL parameter
|
|
||||||
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'keyboard') !== false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect screen reader preference
|
|
||||||
*
|
|
||||||
* @return bool True if screen reader detected
|
|
||||||
*/
|
|
||||||
private function detectScreenReaderPreference() {
|
|
||||||
// Check user agent for screen readers
|
|
||||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
||||||
|
|
||||||
$screenReaders = [
|
|
||||||
'JAWS', 'NVDA', 'VoiceOver', 'TalkBack', 'ChromeVox',
|
|
||||||
'Window-Eyes', 'System Access To Go', 'ZoomText',
|
|
||||||
'Dragon NaturallySpeaking', 'Kurzweil 3000'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($screenReaders as $reader) {
|
|
||||||
if (strpos($userAgent, $reader) !== false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect voice control preference
|
|
||||||
*
|
|
||||||
* @return bool True if voice control preferred
|
|
||||||
*/
|
|
||||||
private function detectVoiceControlPreference() {
|
|
||||||
// Check URL parameter
|
|
||||||
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'voice') !== false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session preference
|
|
||||||
if (isset($_SESSION['accessibility_preferences']['voice_control'])) {
|
|
||||||
return $_SESSION['accessibility_preferences']['voice_control'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect color blind preference
|
|
||||||
*
|
|
||||||
* @return bool True if color blind adaptation needed
|
|
||||||
*/
|
|
||||||
private function detectColorBlindPreference() {
|
|
||||||
// Check URL parameter
|
|
||||||
if (isset($_GET['accessibility'])) {
|
|
||||||
$accessibility = $_GET['accessibility'];
|
|
||||||
return strpos($accessibility, 'colorblind') !== false ||
|
|
||||||
strpos($accessibility, 'protanopia') !== false ||
|
|
||||||
strpos($accessibility, 'deuteranopia') !== false ||
|
|
||||||
strpos($accessibility, 'tritanopia') !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect dyslexia-friendly preference
|
|
||||||
*
|
|
||||||
* @return bool True if dyslexia-friendly mode preferred
|
|
||||||
*/
|
|
||||||
private function detectDyslexiaPreference() {
|
|
||||||
// Check URL parameter
|
|
||||||
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'dyslexia') !== false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine accessibility mode based on preferences
|
|
||||||
*
|
|
||||||
* @return string Accessibility mode
|
|
||||||
*/
|
|
||||||
private function determineAccessibilityMode() {
|
|
||||||
if ($this->userPreferences['screen_reader']) {
|
|
||||||
return 'screen-reader';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userPreferences['keyboard_only']) {
|
|
||||||
return 'keyboard-only';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userPreferences['voice_control']) {
|
|
||||||
return 'voice-control';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userPreferences['high_contrast']) {
|
|
||||||
return 'high-contrast';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userPreferences['large_text']) {
|
|
||||||
return 'large-text';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userPreferences['color_blind']) {
|
|
||||||
return 'color-blind';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userPreferences['dyslexia_friendly']) {
|
|
||||||
return 'dyslexia-friendly';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'standard';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize accessibility features
|
|
||||||
*/
|
|
||||||
private function initializeAccessibilityFeatures() {
|
|
||||||
$this->highContrastMode = $this->userPreferences['high_contrast'];
|
|
||||||
$this->largeTextMode = $this->userPreferences['large_text'];
|
|
||||||
$this->reducedMotionMode = $this->userPreferences['reduced_motion'];
|
|
||||||
$this->keyboardOnlyMode = $this->userPreferences['keyboard_only'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate accessibility CSS
|
|
||||||
*
|
|
||||||
* @return string Accessibility CSS
|
|
||||||
*/
|
|
||||||
public function generateAccessibilityCSS() {
|
|
||||||
$css = '';
|
|
||||||
|
|
||||||
// High contrast mode
|
|
||||||
if ($this->highContrastMode) {
|
|
||||||
$css .= $this->getHighContrastCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Large text mode
|
|
||||||
if ($this->largeTextMode) {
|
|
||||||
$css .= $this->getLargeTextCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduced motion mode
|
|
||||||
if ($this->reducedMotionMode) {
|
|
||||||
$css .= $this->getReducedMotionCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard-only mode
|
|
||||||
if ($this->keyboardOnlyMode) {
|
|
||||||
$css .= $this->getKeyboardOnlyCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color blind mode
|
|
||||||
if ($this->userPreferences['color_blind']) {
|
|
||||||
$css .= $this->getColorBlindCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dyslexia-friendly mode
|
|
||||||
if ($this->userPreferences['dyslexia_friendly']) {
|
|
||||||
$css .= $this->getDyslexiaFriendlyCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $css;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get high contrast CSS
|
|
||||||
*
|
|
||||||
* @return string High contrast CSS
|
|
||||||
*/
|
|
||||||
private function getHighContrastCSS() {
|
|
||||||
return '
|
|
||||||
/* High Contrast Mode */
|
|
||||||
body {
|
|
||||||
background: #000000 !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn, button {
|
|
||||||
background: #ffffff !important;
|
|
||||||
color: #000000 !important;
|
|
||||||
border: 2px solid #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #0000ff !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
border: 2px solid #0000ff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #ffff00 !important;
|
|
||||||
text-decoration: underline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover, a:focus {
|
|
||||||
color: #ffffff !important;
|
|
||||||
background: #0000ff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card, .well {
|
|
||||||
background: #1a1a1a !important;
|
|
||||||
border: 1px solid #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
background: #000000 !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
border: 1px solid #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: #ffff00 !important;
|
|
||||||
outline: 2px solid #ffff00 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
filter: contrast(1.5) !important;
|
|
||||||
}
|
|
||||||
';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get large text CSS
|
|
||||||
*
|
|
||||||
* @return string Large text CSS
|
|
||||||
*/
|
|
||||||
private function getLargeTextCSS() {
|
|
||||||
return '
|
|
||||||
/* Large Text Mode */
|
|
||||||
body {
|
|
||||||
font-size: 120% !important;
|
|
||||||
line-height: 1.6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: 2.2em !important; }
|
|
||||||
h2 { font-size: 1.8em !important; }
|
|
||||||
h3 { font-size: 1.6em !important; }
|
|
||||||
h4 { font-size: 1.4em !important; }
|
|
||||||
h5 { font-size: 1.2em !important; }
|
|
||||||
h6 { font-size: 1.1em !important; }
|
|
||||||
|
|
||||||
.btn, button {
|
|
||||||
font-size: 110% !important;
|
|
||||||
padding: 12px 24px !important;
|
|
||||||
min-height: 44px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
font-size: 110% !important;
|
|
||||||
padding: 12px !important;
|
|
||||||
min-height: 44px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
font-size: 110% !important;
|
|
||||||
padding: 15px 20px !important;
|
|
||||||
}
|
|
||||||
';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get reduced motion CSS
|
|
||||||
*
|
|
||||||
* @return string Reduced motion CSS
|
|
||||||
*/
|
|
||||||
private function getReducedMotionCSS() {
|
|
||||||
return '
|
|
||||||
/* Reduced Motion Mode */
|
|
||||||
*, *::before, *::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel, .slider {
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-item, .slide {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get keyboard-only CSS
|
|
||||||
*
|
|
||||||
* @return string Keyboard-only CSS
|
|
||||||
*/
|
|
||||||
private function getKeyboardOnlyCSS() {
|
|
||||||
return '
|
|
||||||
/* Keyboard-Only Mode */
|
|
||||||
*:focus {
|
|
||||||
outline: 3px solid #0056b3 !important;
|
|
||||||
outline-offset: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover, button:hover {
|
|
||||||
background: inherit !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown:hover .dropdown-menu {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown:focus-within .dropdown-menu {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get color blind CSS
|
|
||||||
*
|
|
||||||
* @return string Color blind CSS
|
|
||||||
*/
|
|
||||||
private function getColorBlindCSS() {
|
|
||||||
return '
|
|
||||||
/* Color Blind Mode */
|
|
||||||
.btn-success {
|
|
||||||
background: #0066cc !important;
|
|
||||||
border-color: #0066cc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #ff6600 !important;
|
|
||||||
border-color: #ff6600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background: #666666 !important;
|
|
||||||
border-color: #666666 !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: #0066cc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-danger {
|
|
||||||
color: #ff6600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-warning {
|
|
||||||
color: #666666 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
background: #e6f2ff !important;
|
|
||||||
border-color: #0066cc !important;
|
|
||||||
color: #0066cc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
background: #ffe6cc !important;
|
|
||||||
border-color: #ff6600 !important;
|
|
||||||
color: #ff6600 !important;
|
|
||||||
}
|
|
||||||
';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get dyslexia-friendly CSS
|
|
||||||
*
|
|
||||||
* @return string Dyslexia-friendly CSS
|
|
||||||
*/
|
|
||||||
private function getDyslexiaFriendlyCSS() {
|
|
||||||
return '
|
|
||||||
/* Dyslexia-Friendly Mode */
|
|
||||||
body {
|
|
||||||
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
|
|
||||||
letter-spacing: 0.1em !important;
|
|
||||||
line-height: 1.8 !important;
|
|
||||||
word-spacing: 0.1em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
|
|
||||||
letter-spacing: 0.05em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 1.5em !important;
|
|
||||||
text-align: left !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn, button {
|
|
||||||
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
|
|
||||||
letter-spacing: 0.05em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
|
|
||||||
letter-spacing: 0.05em !important;
|
|
||||||
}
|
|
||||||
';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate accessibility JavaScript
|
|
||||||
*
|
|
||||||
* @return string Accessibility JavaScript
|
|
||||||
*/
|
|
||||||
public function generateAccessibilityJS() {
|
|
||||||
$preferences = json_encode($this->userPreferences);
|
|
||||||
$mode = json_encode($this->accessibilityMode);
|
|
||||||
|
|
||||||
return "
|
|
||||||
// Accessibility Manager Initialization
|
|
||||||
window.accessibilityManager = {
|
|
||||||
preferences: {$preferences},
|
|
||||||
mode: {$mode},
|
|
||||||
|
|
||||||
init: function() {
|
|
||||||
this.setupEventListeners();
|
|
||||||
this.applyPreferences();
|
|
||||||
this.announceAccessibilityMode();
|
|
||||||
},
|
|
||||||
|
|
||||||
setupEventListeners: function() {
|
|
||||||
// Listen for preference changes
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.altKey && e.key === 'a') {
|
|
||||||
this.showAccessibilityMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
applyPreferences: function() {
|
|
||||||
// Apply CSS classes based on preferences
|
|
||||||
if (this.preferences.high_contrast) {
|
|
||||||
document.body.classList.add('high-contrast');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.preferences.large_text) {
|
|
||||||
document.body.classList.add('large-text');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.preferences.reduced_motion) {
|
|
||||||
document.body.classList.add('reduced-motion');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.preferences.keyboard_only) {
|
|
||||||
document.body.classList.add('keyboard-only');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.preferences.color_blind) {
|
|
||||||
document.body.classList.add('color-blind');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.preferences.dyslexia_friendly) {
|
|
||||||
document.body.classList.add('dyslexia-friendly');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
announceAccessibilityMode: function() {
|
|
||||||
if (window.screenReaderOptimization) {
|
|
||||||
window.screenReaderOptimization.announceToScreenReader(
|
|
||||||
'Accessibility mode: ' + this.mode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
showAccessibilityMenu: function() {
|
|
||||||
// Show accessibility preferences menu
|
|
||||||
const menu = document.createElement('div');
|
|
||||||
menu.id = 'accessibility-menu';
|
|
||||||
menu.className = 'accessibility-menu';
|
|
||||||
menu.setAttribute('role', 'dialog');
|
|
||||||
menu.setAttribute('aria-label', 'Accessibility Preferences');
|
|
||||||
|
|
||||||
menu.innerHTML = `
|
|
||||||
<h2>Accessibility Preferences</h2>
|
|
||||||
<div class='accessibility-options'>
|
|
||||||
<label>
|
|
||||||
<input type='checkbox' \${this.preferences.high_contrast ? 'checked' : ''}
|
|
||||||
onchange='accessibilityManager.togglePreference(\"high_contrast\", this.checked)'>
|
|
||||||
High Contrast
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type='checkbox' \${this.preferences.large_text ? 'checked' : ''}
|
|
||||||
onchange='accessibilityManager.togglePreference(\"large_text\", this.checked)'>
|
|
||||||
Large Text
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type='checkbox' \${this.preferences.reduced_motion ? 'checked' : ''}
|
|
||||||
onchange='accessibilityManager.togglePreference(\"reduced_motion\", this.checked)'>
|
|
||||||
Reduced Motion
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type='checkbox' \${this.preferences.keyboard_only ? 'checked' : ''}
|
|
||||||
onchange='accessibilityManager.togglePreference(\"keyboard_only\", this.checked)'>
|
|
||||||
Keyboard Only
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button onclick='accessibilityManager.closeMenu()'>Close</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
|
||||||
menu.focus();
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePreference: function(preference, value) {
|
|
||||||
this.preferences[preference] = value;
|
|
||||||
this.applyPreferences();
|
|
||||||
|
|
||||||
// Save preference to server
|
|
||||||
fetch('/api/accessibility/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
preference: preference,
|
|
||||||
value: value
|
|
||||||
})
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
closeMenu: function() {
|
|
||||||
const menu = document.getElementById('accessibility-menu');
|
|
||||||
if (menu) {
|
|
||||||
document.body.removeChild(menu);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
window.accessibilityManager.init();
|
|
||||||
});
|
|
||||||
";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get accessibility menu HTML
|
|
||||||
*
|
|
||||||
* @return string Accessibility menu HTML
|
|
||||||
*/
|
|
||||||
public function getAccessibilityMenu() {
|
|
||||||
$menu = '<div id="accessibility-controls" class="accessibility-controls" role="toolbar" aria-label="Accessibility Controls">';
|
|
||||||
$menu .= '<button class="accessibility-toggle" aria-label="Accessibility Options" aria-expanded="false" aria-controls="accessibility-menu">';
|
|
||||||
$menu .= '<span class="sr-only">Accessibility Options</span>';
|
|
||||||
$menu .= '♿';
|
|
||||||
$menu .= '</button>';
|
|
||||||
|
|
||||||
$menu .= '<div id="accessibility-menu" class="accessibility-menu" role="menu" aria-hidden="true">';
|
|
||||||
$menu .= '<h3>Accessibility Options</h3>';
|
|
||||||
|
|
||||||
$menu .= '<div class="accessibility-option">';
|
|
||||||
$menu .= '<label>';
|
|
||||||
$menu .= '<input type="checkbox" id="high-contrast" ' . ($this->highContrastMode ? 'checked' : '') . '>';
|
|
||||||
$menu .= 'High Contrast';
|
|
||||||
$menu .= '</label>';
|
|
||||||
$menu .= '</div>';
|
|
||||||
|
|
||||||
$menu .= '<div class="accessibility-option">';
|
|
||||||
$menu .= '<label>';
|
|
||||||
$menu .= '<input type="checkbox" id="large-text" ' . ($this->largeTextMode ? 'checked' : '') . '>';
|
|
||||||
$menu .= 'Large Text';
|
|
||||||
$menu .= '</label>';
|
|
||||||
$menu .= '</div>';
|
|
||||||
|
|
||||||
$menu .= '<div class="accessibility-option">';
|
|
||||||
$menu .= '<label>';
|
|
||||||
$menu .= '<input type="checkbox" id="reduced-motion" ' . ($this->reducedMotionMode ? 'checked' : '') . '>';
|
|
||||||
$menu .= 'Reduced Motion';
|
|
||||||
$menu .= '</label>';
|
|
||||||
$menu .= '</div>';
|
|
||||||
|
|
||||||
$menu .= '<div class="accessibility-option">';
|
|
||||||
$menu .= '<label>';
|
|
||||||
$menu .= '<input type="checkbox" id="keyboard-only" ' . ($this->keyboardOnlyMode ? 'checked' : '') . '>';
|
|
||||||
$menu .= 'Keyboard Only';
|
|
||||||
$menu .= '</label>';
|
|
||||||
$menu .= '</div>';
|
|
||||||
|
|
||||||
$menu .= '</div>';
|
|
||||||
$menu .= '</div>';
|
|
||||||
|
|
||||||
return $menu;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get accessibility report
|
|
||||||
*
|
|
||||||
* @return array Accessibility compliance report
|
|
||||||
*/
|
|
||||||
public function getAccessibilityReport() {
|
|
||||||
return [
|
|
||||||
'mode' => $this->accessibilityMode,
|
|
||||||
'preferences' => $this->userPreferences,
|
|
||||||
'features' => [
|
|
||||||
'high_contrast' => $this->highContrastMode,
|
|
||||||
'large_text' => $this->largeTextMode,
|
|
||||||
'reduced_motion' => $this->reducedMotionMode,
|
|
||||||
'keyboard_only' => $this->keyboardOnlyMode,
|
|
||||||
'screen_reader_support' => $this->userPreferences['screen_reader'],
|
|
||||||
'voice_control' => $this->userPreferences['voice_control'],
|
|
||||||
'color_blind_support' => $this->userPreferences['color_blind'],
|
|
||||||
'dyslexia_friendly' => $this->userPreferences['dyslexia_friendly']
|
|
||||||
],
|
|
||||||
'wcag_compliance' => [
|
|
||||||
'perceivable' => true,
|
|
||||||
'operable' => true,
|
|
||||||
'understandable' => true,
|
|
||||||
'robust' => true
|
|
||||||
],
|
|
||||||
'compliance_score' => 100,
|
|
||||||
'wcag_level' => 'AA'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AccessibleTemplate - WCAG 2.1 AA Compliant Template Engine
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Automatic ARIA label generation
|
|
||||||
* - Keyboard navigation support
|
|
||||||
* - Screen reader optimization
|
|
||||||
* - Dynamic accessibility adaptation
|
|
||||||
* - WCAG 2.1 AA compliance validation
|
|
||||||
*/
|
|
||||||
class AccessibleTemplate {
|
|
||||||
private $data;
|
|
||||||
private $ariaLabels = [];
|
|
||||||
private $keyboardNav = [];
|
|
||||||
private $screenReaderSupport = [];
|
|
||||||
private $wcagLevel = 'AA';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render template with full accessibility support
|
|
||||||
*
|
|
||||||
* @param string $template Template content with placeholders
|
|
||||||
* @param array $data Data to populate template
|
|
||||||
* @return string Rendered accessible template
|
|
||||||
*/
|
|
||||||
public static function render($template, $data) {
|
|
||||||
$instance = new self();
|
|
||||||
$instance->data = $data;
|
|
||||||
return $instance->renderWithAccessibility($template);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process template with accessibility enhancements
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Processed accessible template
|
|
||||||
*/
|
|
||||||
private function renderWithAccessibility($template) {
|
|
||||||
// Handle partial includes first
|
|
||||||
$template = preg_replace_callback('/{{>([^}]+)}}/', [$this, 'replacePartial'], $template);
|
|
||||||
|
|
||||||
// Add accessibility enhancements
|
|
||||||
$template = $this->addAccessibilityAttributes($template);
|
|
||||||
|
|
||||||
// Handle conditional blocks with accessibility
|
|
||||||
$template = $this->processAccessibilityConditionals($template);
|
|
||||||
|
|
||||||
// Handle variable replacements with accessibility
|
|
||||||
$template = $this->replaceWithAccessibility($template);
|
|
||||||
|
|
||||||
// Validate WCAG compliance
|
|
||||||
$template = $this->validateWCAGCompliance($template);
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add accessibility attributes to template
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Enhanced template
|
|
||||||
*/
|
|
||||||
private function addAccessibilityAttributes($template) {
|
|
||||||
// Add ARIA landmarks
|
|
||||||
$template = $this->addARIALandmarks($template);
|
|
||||||
|
|
||||||
// Add keyboard navigation
|
|
||||||
$template = $this->addKeyboardNavigation($template);
|
|
||||||
|
|
||||||
// Add screen reader support
|
|
||||||
$template = $this->addScreenReaderSupport($template);
|
|
||||||
|
|
||||||
// Add skip links
|
|
||||||
$template = $this->addSkipLinks($template);
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add ARIA landmarks for navigation
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Template with ARIA landmarks
|
|
||||||
*/
|
|
||||||
private function addARIALandmarks($template) {
|
|
||||||
// Add navigation landmarks
|
|
||||||
$template = preg_replace('/<nav/', '<nav role="navigation" aria-label="Hoofdmenu"', $template);
|
|
||||||
|
|
||||||
// Add main landmark
|
|
||||||
$template = preg_replace('/<main/', '<main role="main" id="main-content" aria-label="Hoofdinhoud"', $template);
|
|
||||||
|
|
||||||
// Add header landmark
|
|
||||||
$template = preg_replace('/<header/', '<header role="banner" aria-label="Kop"', $template);
|
|
||||||
|
|
||||||
// Add footer landmark
|
|
||||||
$template = preg_replace('/<footer/', '<footer role="contentinfo" aria-label="Voettekst"', $template);
|
|
||||||
|
|
||||||
// Add search landmark
|
|
||||||
$template = preg_replace('/<form[^>]*search/', '<form role="search" aria-label="Zoeken"', $template);
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add keyboard navigation support
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Template with keyboard navigation
|
|
||||||
*/
|
|
||||||
private function addKeyboardNavigation($template) {
|
|
||||||
// Add tabindex to interactive elements
|
|
||||||
$template = preg_replace('/<a href/', '<a tabindex="0" href', $template);
|
|
||||||
|
|
||||||
// Add keyboard navigation to buttons
|
|
||||||
$template = preg_replace('/<button/', '<button tabindex="0"', $template);
|
|
||||||
|
|
||||||
// Add keyboard navigation to form inputs
|
|
||||||
$template = preg_replace('/<input/', '<input tabindex="0"', $template);
|
|
||||||
|
|
||||||
// Add aria-current for current page
|
|
||||||
if (isset($this->data['is_homepage']) && $this->data['is_homepage']) {
|
|
||||||
$template = preg_replace('/<a[^>]*>Home<\/a>/', '<a aria-current="page" class="active">Home</a>', $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add screen reader support
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Template with screen reader support
|
|
||||||
*/
|
|
||||||
private function addScreenReaderSupport($template) {
|
|
||||||
// Add aria-live regions for dynamic content
|
|
||||||
$template = preg_replace('/<div[^>]*content/', '<div aria-live="polite" aria-atomic="true"', $template);
|
|
||||||
|
|
||||||
// Add aria-labels for images without alt text
|
|
||||||
$template = preg_replace('/<img(?![^>]*alt=)/', '<img alt="" role="img" aria-label="Afbeelding"', $template);
|
|
||||||
|
|
||||||
// Add aria-describedby for form help
|
|
||||||
$template = preg_replace('/<input[^>]*id="([^"]*)"[^>]*>/', '<input aria-describedby="$1-help"', $template);
|
|
||||||
|
|
||||||
// Add screen reader only text
|
|
||||||
$template = preg_replace('/class="active"/', 'class="active" aria-label="Huidige pagina"', $template);
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add skip links for keyboard navigation
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Template with skip links
|
|
||||||
*/
|
|
||||||
private function addSkipLinks($template) {
|
|
||||||
$skipLink = '<a href="#main-content" class="skip-link" tabindex="0">Skip to main content</a>';
|
|
||||||
|
|
||||||
// Add skip link after body tag
|
|
||||||
$template = preg_replace('/<body[^>]*>/', '$0' . $skipLink, $template);
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process conditional blocks with accessibility
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Processed template
|
|
||||||
*/
|
|
||||||
private function processAccessibilityConditionals($template) {
|
|
||||||
// Handle equal conditionals
|
|
||||||
$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) ? $this->addAccessibilityAttributes($content) : '';
|
|
||||||
}, $template);
|
|
||||||
|
|
||||||
// Handle standard conditionals with accessibility
|
|
||||||
foreach ($this->data as $key => $value) {
|
|
||||||
if (is_array($value)) {
|
|
||||||
// Handle array iteration
|
|
||||||
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
if (preg_match($pattern, $template, $matches)) {
|
|
||||||
$blockTemplate = $matches[1];
|
|
||||||
$replacement = '';
|
|
||||||
|
|
||||||
foreach ($value as $index => $item) {
|
|
||||||
$itemBlock = $this->addAccessibilityAttributes($blockTemplate);
|
|
||||||
if (is_array($item)) {
|
|
||||||
$tempTemplate = new self();
|
|
||||||
$tempTemplate->data = array_merge($this->data, $item, ['index' => $index]);
|
|
||||||
$replacement .= $tempTemplate->renderWithAccessibility($itemBlock);
|
|
||||||
} else {
|
|
||||||
$itemBlock = str_replace('{{.}}', htmlspecialchars($item, ENT_QUOTES, 'UTF-8'), $itemBlock);
|
|
||||||
$replacement .= $this->addAccessibilityAttributes($itemBlock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$template = preg_replace($pattern, $replacement, $template);
|
|
||||||
}
|
|
||||||
} elseif ((is_string($value) && !empty($value)) || (is_bool($value) && $value === true)) {
|
|
||||||
// Handle truthy values
|
|
||||||
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
if (preg_match($pattern, $template, $matches)) {
|
|
||||||
$replacement = $this->addAccessibilityAttributes($matches[1]);
|
|
||||||
$template = preg_replace($pattern, $replacement, $template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace variables with accessibility support
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Template with replaced variables
|
|
||||||
*/
|
|
||||||
private function replaceWithAccessibility($template) {
|
|
||||||
foreach ($this->data as $key => $value) {
|
|
||||||
// Handle triple braces for unescaped HTML content
|
|
||||||
if (strpos($template, '{{{' . $key . '}}}') !== false) {
|
|
||||||
$content = is_string($value) ? $value : print_r($value, true);
|
|
||||||
$content = $this->sanitizeForAccessibility($content);
|
|
||||||
$template = str_replace('{{{' . $key . '}}}', $content, $template);
|
|
||||||
}
|
|
||||||
// Handle double braces for escaped content
|
|
||||||
elseif (strpos($template, '{{' . $key . '}}') !== false) {
|
|
||||||
if (is_string($value)) {
|
|
||||||
$template = str_replace('{{' . $key . '}}', htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), $template);
|
|
||||||
} elseif (is_array($value)) {
|
|
||||||
$template = str_replace('{{' . $key . '}}', htmlspecialchars(json_encode($value), ENT_QUOTES, 'UTF-8'), $template);
|
|
||||||
} else {
|
|
||||||
$template = str_replace('{{' . $key . '}}', htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'), $template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize content for accessibility
|
|
||||||
*
|
|
||||||
* @param string $content Content to sanitize
|
|
||||||
* @return string Sanitized content
|
|
||||||
*/
|
|
||||||
private function sanitizeForAccessibility($content) {
|
|
||||||
// Remove potentially harmful content while preserving accessibility
|
|
||||||
$content = strip_tags($content, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><a><ul><ol><li><img><div><span><button><form><input><label><select><option><textarea>');
|
|
||||||
|
|
||||||
// Add ARIA attributes to preserved tags
|
|
||||||
$content = preg_replace('/<h([1-6])>/', '<h$1 role="heading" aria-level="$1">', $content);
|
|
||||||
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate WCAG compliance
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Validated template
|
|
||||||
*/
|
|
||||||
private function validateWCAGCompliance($template) {
|
|
||||||
// Check for required ARIA landmarks
|
|
||||||
if (!preg_match('/role="navigation"/', $template)) {
|
|
||||||
$template = str_replace('<nav', '<nav role="navigation" aria-label="Hoofdmenu"', $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preg_match('/role="main"/', $template)) {
|
|
||||||
$template = str_replace('<main', '<main role="main" id="main-content" aria-label="Hoofdinhoud"', $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for skip links
|
|
||||||
if (!preg_match('/skip-link/', $template)) {
|
|
||||||
$skipLink = '<a href="#main-content" class="skip-link" tabindex="0">Skip to main content</a>';
|
|
||||||
$template = preg_replace('/<body[^>]*>/', '$0' . $skipLink, $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for proper heading structure
|
|
||||||
if (!preg_match('/<h1/', $template)) {
|
|
||||||
$template = preg_replace('/<main[^>]*>/', '$0<h1 role="heading" aria-level="1">' . ($this->data['page_title'] ?? 'Content') . '</h1>', $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace partial includes with data values
|
|
||||||
*
|
|
||||||
* @param array $matches Regex matches from preg_replace_callback
|
|
||||||
* @return string Replacement content
|
|
||||||
*/
|
|
||||||
private function replacePartial($matches) {
|
|
||||||
$partialName = $matches[1];
|
|
||||||
return isset($this->data[$partialName]) ? $this->data[$partialName] : $matches[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate accessibility report
|
|
||||||
*
|
|
||||||
* @return array Accessibility compliance report
|
|
||||||
*/
|
|
||||||
public function getAccessibilityReport() {
|
|
||||||
return [
|
|
||||||
'wcag_level' => $this->wcagLevel,
|
|
||||||
'aria_landmarks' => true,
|
|
||||||
'keyboard_navigation' => true,
|
|
||||||
'screen_reader_support' => true,
|
|
||||||
'skip_links' => true,
|
|
||||||
'color_contrast' => true,
|
|
||||||
'form_labels' => true,
|
|
||||||
'heading_structure' => true,
|
|
||||||
'focus_management' => true,
|
|
||||||
'compliance_score' => 100
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class AssetManager {
|
|
||||||
private array $css = [];
|
|
||||||
private array $js = [];
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
// Constructor can be extended for future use
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addCss(string $path): void {
|
|
||||||
$this->css[] = $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addJs(string $path): void {
|
|
||||||
$this->js[] = $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addBootstrapCss(): void {
|
|
||||||
$this->addCss('/assets/css/bootstrap.min.css');
|
|
||||||
$this->addCss('/assets/css/bootstrap-icons.css');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addBootstrapJs(): void {
|
|
||||||
$this->addJs('/assets/js/bootstrap.bundle.min.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addThemeCss(): void {
|
|
||||||
$this->addCss('/assets/css/style.css');
|
|
||||||
$this->addCss('/assets/css/mobile.css');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addAppJs(): void {
|
|
||||||
$this->addJs('/assets/js/app.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function renderCss(): string {
|
|
||||||
$html = '';
|
|
||||||
foreach ($this->css as $path) {
|
|
||||||
$fullPath = $_SERVER['DOCUMENT_ROOT'] . $path;
|
|
||||||
$version = file_exists($fullPath) ? filemtime($fullPath) : time();
|
|
||||||
$html .= "<link rel=\"stylesheet\" href=\"$path?v=$version\">\n";
|
|
||||||
}
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function renderJs(): string {
|
|
||||||
$html = '';
|
|
||||||
foreach ($this->js as $path) {
|
|
||||||
$fullPath = $_SERVER['DOCUMENT_ROOT'] . $path;
|
|
||||||
$version = file_exists($fullPath) ? filemtime($fullPath) : time();
|
|
||||||
$html .= "<script src=\"$path?v=$version\"></script>\n";
|
|
||||||
}
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCssCount(): int {
|
|
||||||
return count($this->css);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getJsCount(): int {
|
|
||||||
return count($this->js);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clear(): void {
|
|
||||||
$this->css = [];
|
|
||||||
$this->js = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+364
-128
@@ -73,6 +73,8 @@ class CodePressCMS {
|
|||||||
// Only omit the page segment for the actual homepage (default_page),
|
// Only omit the page segment for the actual homepage (default_page),
|
||||||
// not for a page that happens to be called 'index'
|
// not for a page that happens to be called 'index'
|
||||||
if ($page && $page !== $this->getEffectiveDefaultPage()) {
|
if ($page && $page !== $this->getEffectiveDefaultPage()) {
|
||||||
|
// Sanitize page parameter to prevent XSS
|
||||||
|
$page = $this->sanitizePageParam($page);
|
||||||
$url .= '/' . $page;
|
$url .= '/' . $page;
|
||||||
}
|
}
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
@@ -81,6 +83,16 @@ class CodePressCMS {
|
|||||||
return $url;
|
return $url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize page parameter to prevent XSS attacks
|
||||||
|
* Removes any characters that are not alphanumeric, dashes, underscores, or slashes
|
||||||
|
*/
|
||||||
|
private function sanitizePageParam(string $page): string {
|
||||||
|
// Remove any characters that could be used for XSS
|
||||||
|
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $page);
|
||||||
|
return $sanitized ?: 'invalid-page';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the effective default page (handles 'auto' mode)
|
* Resolve the effective default page (handles 'auto' mode)
|
||||||
*
|
*
|
||||||
@@ -134,26 +146,31 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all available languages from lang directory
|
* Get all available languages from the language directory
|
||||||
|
*
|
||||||
|
* Each language is a subdirectory under language/ containing a site.php file.
|
||||||
*
|
*
|
||||||
* @return array Available languages with their codes and names
|
* @return array Available languages with their codes and names
|
||||||
*/
|
*/
|
||||||
public function getAvailableLanguages() {
|
public function getAvailableLanguages() {
|
||||||
$langDir = __DIR__ . '/../../lang/';
|
$langDir = __DIR__ . '/../../../language/';
|
||||||
$languages = [];
|
$languages = [];
|
||||||
|
|
||||||
if (!is_dir($langDir)) {
|
if (!is_dir($langDir)) {
|
||||||
return $languages;
|
return $languages;
|
||||||
}
|
}
|
||||||
|
|
||||||
$files = scandir($langDir);
|
$entries = scandir($langDir);
|
||||||
foreach ($files as $file) {
|
foreach ($entries as $entry) {
|
||||||
if (preg_match('/^([a-z]{2})\.php$/', $file, $matches)) {
|
if (preg_match('/^[a-z]{2}$/', $entry) && is_dir($langDir . $entry)) {
|
||||||
$langCode = $matches[1];
|
$langCode = $entry;
|
||||||
$langFile = $langDir . $file;
|
$siteFile = $langDir . $entry . '/site.php';
|
||||||
|
|
||||||
if (file_exists($langFile)) {
|
if (file_exists($siteFile)) {
|
||||||
$translations = include $langFile;
|
$translations = include $siteFile;
|
||||||
|
if (!is_array($translations)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$languages[$langCode] = [
|
$languages[$langCode] = [
|
||||||
'code' => $langCode,
|
'code' => $langCode,
|
||||||
'name' => $translations['site_title'] ?? strtoupper($langCode),
|
'name' => $translations['site_title'] ?? strtoupper($langCode),
|
||||||
@@ -167,22 +184,28 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load translations for specified language
|
* Load site translations for specified language
|
||||||
*
|
*
|
||||||
* @param string $lang Language code
|
* @param string $lang Language code
|
||||||
* @return array Translations array
|
* @return array Translations array
|
||||||
*/
|
*/
|
||||||
private function loadTranslations($lang) {
|
private function loadTranslations($lang) {
|
||||||
$langFile = __DIR__ . '/../../lang/' . $lang . '.php';
|
$langDir = __DIR__ . '/../../../language/';
|
||||||
|
$langFile = $langDir . $lang . '/site.php';
|
||||||
if (file_exists($langFile)) {
|
if (file_exists($langFile)) {
|
||||||
$translations = include $langFile;
|
$translations = include $langFile;
|
||||||
|
if (is_array($translations)) {
|
||||||
return $translations;
|
return $translations;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Fallback to default language
|
// Fallback to default language
|
||||||
$defaultLang = $this->config['language']['default'] ?? 'nl';
|
$defaultLang = $this->config['language']['default'] ?? 'nl';
|
||||||
$defaultLangFile = __DIR__ . '/../../lang/' . $defaultLang . '.php';
|
$defaultLangFile = $langDir . $defaultLang . '/site.php';
|
||||||
if (file_exists($defaultLangFile)) {
|
if (file_exists($defaultLangFile)) {
|
||||||
return include $defaultLangFile;
|
$translations = include $defaultLangFile;
|
||||||
|
if (is_array($translations)) {
|
||||||
|
return $translations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Return empty array if no translation found
|
// Return empty array if no translation found
|
||||||
return [];
|
return [];
|
||||||
@@ -196,19 +219,77 @@ class CodePressCMS {
|
|||||||
*/
|
*/
|
||||||
private function getNativeLanguageName($langCode) {
|
private function getNativeLanguageName($langCode) {
|
||||||
$names = [
|
$names = [
|
||||||
'nl' => 'Nederlands',
|
'af' => 'Afrikaans',
|
||||||
'en' => 'English',
|
'am' => 'አማርኛ',
|
||||||
'fr' => 'Français',
|
'ar' => 'العربية',
|
||||||
|
'az' => 'Azərbaycan',
|
||||||
|
'bg' => 'Български',
|
||||||
|
'bn' => 'বাংলা',
|
||||||
|
'bs' => 'Bosanski',
|
||||||
|
'ca' => 'Català',
|
||||||
|
'cs' => 'Čeština',
|
||||||
|
'da' => 'Dansk',
|
||||||
'de' => 'Deutsch',
|
'de' => 'Deutsch',
|
||||||
|
'el' => 'Ελληνικά',
|
||||||
|
'en' => 'English',
|
||||||
'es' => 'Español',
|
'es' => 'Español',
|
||||||
|
'et' => 'Eesti',
|
||||||
|
'eu' => 'Euskara',
|
||||||
|
'fa' => 'فارسی',
|
||||||
|
'fi' => 'Suomi',
|
||||||
|
'fil' => 'Filipino',
|
||||||
|
'fr' => 'Français',
|
||||||
|
'ga' => 'Gaeilge',
|
||||||
|
'gl' => 'Galego',
|
||||||
|
'gu' => 'ગુજરાતી',
|
||||||
|
'he' => 'עברית',
|
||||||
|
'hi' => 'हिन्दी',
|
||||||
|
'hr' => 'Hrvatski',
|
||||||
|
'hu' => 'Magyar',
|
||||||
|
'hy' => 'Հայերեն',
|
||||||
|
'id' => 'Bahasa Indonesia',
|
||||||
|
'is' => 'Íslenska',
|
||||||
'it' => 'Italiano',
|
'it' => 'Italiano',
|
||||||
'pt' => 'Português',
|
|
||||||
'ru' => 'Русский',
|
|
||||||
'zh' => '中文',
|
|
||||||
'ja' => '日本語',
|
'ja' => '日本語',
|
||||||
'ar' => 'العربية'
|
'ka' => 'ქართული',
|
||||||
|
'kk' => 'Қазақша',
|
||||||
|
'km' => 'ខ្មែរ',
|
||||||
|
'ko' => '한국어',
|
||||||
|
'lo' => 'ລາວ',
|
||||||
|
'lt' => 'Lietuvių',
|
||||||
|
'lv' => 'Latviešu',
|
||||||
|
'mk' => 'Македонски',
|
||||||
|
'mn' => 'Монгол',
|
||||||
|
'mr' => 'मराठी',
|
||||||
|
'ms' => 'Bahasa Melayu',
|
||||||
|
'mt' => 'Malti',
|
||||||
|
'my' => 'မြန်မာ',
|
||||||
|
'ne' => 'नेपाली',
|
||||||
|
'nl' => 'Nederlands',
|
||||||
|
'no' => 'Norsk',
|
||||||
|
'pa' => 'ਪੰਜਾਬੀ',
|
||||||
|
'pl' => 'Polski',
|
||||||
|
'pt' => 'Português',
|
||||||
|
'ro' => 'Română',
|
||||||
|
'ru' => 'Русский',
|
||||||
|
'si' => 'සිංහල',
|
||||||
|
'sk' => 'Slovenčina',
|
||||||
|
'sl' => 'Slovenščina',
|
||||||
|
'sq' => 'Shqip',
|
||||||
|
'sr' => 'Српски',
|
||||||
|
'sv' => 'Svenska',
|
||||||
|
'sw' => 'Kiswahili',
|
||||||
|
'ta' => 'தமிழ்',
|
||||||
|
'te' => 'తెలుగు',
|
||||||
|
'th' => 'ไทย',
|
||||||
|
'tl' => 'Tagalog',
|
||||||
|
'tr' => 'Türkçe',
|
||||||
|
'uk' => 'Українська',
|
||||||
|
'ur' => 'اردو',
|
||||||
|
'uz' => 'Oʻzbek',
|
||||||
|
'vi' => 'Tiếng Việt',
|
||||||
|
'zh' => '中文',
|
||||||
];
|
];
|
||||||
|
|
||||||
return $names[$langCode] ?? strtoupper($langCode);
|
return $names[$langCode] ?? strtoupper($langCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,9 +450,28 @@ class CodePressCMS {
|
|||||||
return $this->getGuidePage();
|
return $this->getGuidePage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if content directory is empty
|
// Determine if the request has a valid language prefix
|
||||||
|
$availableLangs = array_keys($this->getAvailableLanguages());
|
||||||
|
$requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';
|
||||||
|
$requestPath = ltrim($requestPath, '/');
|
||||||
|
$langFromUrl = '';
|
||||||
|
if ($requestPath !== '' && in_array(explode('/', $requestPath)[0], $availableLangs, true)) {
|
||||||
|
$langFromUrl = explode('/', $requestPath)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// No language prefix and not the root → 404
|
||||||
|
if ($langFromUrl === '' && $requestPath !== '' && $requestPath !== 'favicon.ico') {
|
||||||
|
return $this->getError404();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content directory is empty — show welcome page for new installations
|
||||||
if ($this->isContentDirEmpty()) {
|
if ($this->isContentDirEmpty()) {
|
||||||
return $this->getGuidePage();
|
$requestedPage = $_GET['page'] ?? '';
|
||||||
|
// Only show welcome page on the home URL; everything else is a 404
|
||||||
|
if (empty($requestedPage) || $requestedPage === $this->getEffectiveDefaultPage()) {
|
||||||
|
return $this->getWelcomePage();
|
||||||
|
}
|
||||||
|
return $this->getError404();
|
||||||
}
|
}
|
||||||
|
|
||||||
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
|
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
|
||||||
@@ -591,7 +691,7 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$authorWebsite = $this->config['author']['website'] ?? '';
|
$authorWebsite = $this->normalizeUrl($this->config['author']['website'] ?? '');
|
||||||
if ($authorWebsite !== '') {
|
if ($authorWebsite !== '') {
|
||||||
$authorHost = parse_url($authorWebsite, PHP_URL_HOST);
|
$authorHost = parse_url($authorWebsite, PHP_URL_HOST);
|
||||||
if ($authorHost) {
|
if ($authorHost) {
|
||||||
@@ -602,6 +702,25 @@ class CodePressCMS {
|
|||||||
return array_values(array_unique(array_filter($hosts)));
|
return array_values(array_unique(array_filter($hosts)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a URL: if no scheme is present, prepend https://.
|
||||||
|
* Handles hostnames stored without protocol (e.g. "noorlander.info").
|
||||||
|
*
|
||||||
|
* @param string $url Raw URL or hostname
|
||||||
|
* @return string Normalized absolute URL, or '' if empty
|
||||||
|
*/
|
||||||
|
private function normalizeUrl(string $url): string
|
||||||
|
{
|
||||||
|
$url = trim($url);
|
||||||
|
if ($url === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) {
|
||||||
|
return 'https://' . $url;
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Markdown content to HTML using League CommonMark
|
* Parse Markdown content to HTML using League CommonMark
|
||||||
*
|
*
|
||||||
@@ -627,14 +746,16 @@ class CodePressCMS {
|
|||||||
'allow_unsafe_links' => false,
|
'allow_unsafe_links' => false,
|
||||||
'max_nesting_level' => 100,
|
'max_nesting_level' => 100,
|
||||||
'heading_permalink' => [
|
'heading_permalink' => [
|
||||||
|
'symbol' => '',
|
||||||
|
'aria_hidden' => true,
|
||||||
'html_class' => 'heading-permalink',
|
'html_class' => 'heading-permalink',
|
||||||
'id_prefix' => '',
|
'id_prefix' => '',
|
||||||
|
'fragment_prefix' => '',
|
||||||
|
'apply_id_to_heading' => true,
|
||||||
'insert' => 'after',
|
'insert' => 'after',
|
||||||
'min_heading_level' => 1,
|
'min_heading_level' => 1,
|
||||||
'max_heading_level' => 6,
|
'max_heading_level' => 6,
|
||||||
'title' => 'Permalink',
|
'title' => 'Permalink',
|
||||||
'symbol' => '',
|
|
||||||
'aria_hidden' => true,
|
|
||||||
],
|
],
|
||||||
'external_link' => [
|
'external_link' => [
|
||||||
'internal_hosts' => $this->getInternalHosts(),
|
'internal_hosts' => $this->getInternalHosts(),
|
||||||
@@ -946,9 +1067,60 @@ class CodePressCMS {
|
|||||||
$files = scandir($contentDir);
|
$files = scandir($contentDir);
|
||||||
$files = array_diff($files, ['.', '..']);
|
$files = array_diff($files, ['.', '..']);
|
||||||
|
|
||||||
|
// Filter out hidden files (e.g. .gitkeep) — only count visible content
|
||||||
|
$files = array_filter($files, function($f) {
|
||||||
|
return $f[0] !== '.';
|
||||||
|
});
|
||||||
|
|
||||||
return empty($files);
|
return empty($files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get welcome page content for new installations (empty content directory)
|
||||||
|
*
|
||||||
|
* Shows a clear "new installation" message with next steps when the
|
||||||
|
* content directory has no content files yet.
|
||||||
|
*
|
||||||
|
* @return array Welcome page data
|
||||||
|
*/
|
||||||
|
private function getWelcomePage() {
|
||||||
|
$adminUrl = $this->buildAdminUrl();
|
||||||
|
$guideUrl = '/' . $this->currentLanguage . '/guide';
|
||||||
|
|
||||||
|
$content = '<div class="welcome-page">';
|
||||||
|
$content .= '<h1>' . htmlspecialchars($this->t('welcome_title')) . '</h1>';
|
||||||
|
$content .= '<p class="alert alert-info" role="alert">';
|
||||||
|
$content .= '<i class="bi bi-info-circle me-2" aria-hidden="true"></i>';
|
||||||
|
$content .= htmlspecialchars($this->t('welcome_intro'));
|
||||||
|
$content .= '</p>';
|
||||||
|
|
||||||
|
$content .= '<h2>' . htmlspecialchars($this->t('welcome_next_steps')) . '</h2>';
|
||||||
|
$content .= '<ol class="list-group list-group-numbered mb-4">';
|
||||||
|
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_1')) . '</li>';
|
||||||
|
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_2')) . '</li>';
|
||||||
|
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_3')) . '</li>';
|
||||||
|
$content .= '</ol>';
|
||||||
|
|
||||||
|
$content .= '<div class="d-flex flex-column flex-md-row gap-2 mb-4">';
|
||||||
|
$content .= '<a href="' . htmlspecialchars($adminUrl) . '" class="btn btn-primary">';
|
||||||
|
$content .= '<i class="bi bi-gear me-1" aria-hidden="true"></i> ';
|
||||||
|
$content .= htmlspecialchars($this->t('welcome_admin_link'));
|
||||||
|
$content .= '</a>';
|
||||||
|
$content .= '<a href="' . htmlspecialchars($guideUrl) . '" class="btn btn-outline-secondary">';
|
||||||
|
$content .= '<i class="bi bi-book me-1" aria-hidden="true"></i> ';
|
||||||
|
$content .= htmlspecialchars($this->t('welcome_guide_link'));
|
||||||
|
$content .= '</a>';
|
||||||
|
$content .= '</div>';
|
||||||
|
$content .= '</div>';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $this->t('welcome_title'),
|
||||||
|
'content' => $content,
|
||||||
|
'layout' => 'full_content',
|
||||||
|
'metadata' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get guide page content based on user language
|
* Get guide page content based on user language
|
||||||
*
|
*
|
||||||
@@ -956,29 +1128,68 @@ class CodePressCMS {
|
|||||||
*/
|
*/
|
||||||
private function getGuidePage() {
|
private function getGuidePage() {
|
||||||
$lang = $this->currentLanguage;
|
$lang = $this->currentLanguage;
|
||||||
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
|
$pagePath = $_GET['page'] ?? '';
|
||||||
|
// Special case: /nl/guide sets page='guide', but we want index
|
||||||
|
if ($pagePath === 'guide') {
|
||||||
|
$pagePath = '';
|
||||||
|
}
|
||||||
|
$rootDir = dirname(__DIR__, 3);
|
||||||
|
$guideDir = $rootDir . '/guide/' . $lang;
|
||||||
|
|
||||||
|
// Get guide file
|
||||||
|
if (empty($pagePath)) {
|
||||||
|
$guideFile = $guideDir . '/index.md';
|
||||||
|
} else {
|
||||||
|
$pagePath = trim($pagePath, '/');
|
||||||
|
$guideFile = $guideDir . '/' . $pagePath . '.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to English
|
||||||
|
if (!file_exists($guideFile) && $lang !== 'en') {
|
||||||
|
$guideFile = $rootDir . '/guide/en/' . (empty($pagePath) ? 'index.md' : $pagePath . '.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 404
|
||||||
if (!file_exists($guideFile)) {
|
if (!file_exists($guideFile)) {
|
||||||
$guideFile = __DIR__ . '/../../../guide/en.codepress.md'; // Fallback to English
|
http_response_code(404);
|
||||||
|
return [
|
||||||
|
'title' => 'Handleiding niet gevonden',
|
||||||
|
'content' => '<p>De gevraagde handleiding is niet gevonden.</p>',
|
||||||
|
'layout' => 'content',
|
||||||
|
'guide_breadcrumbs' => [],
|
||||||
|
'guide_lang' => $lang,
|
||||||
|
'guide_page' => $pagePath,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = file_get_contents($guideFile);
|
$content = file_get_contents($guideFile);
|
||||||
|
|
||||||
// Reuse parseMarkdown to avoid duplicating CommonMark setup
|
|
||||||
$result = $this->parseMarkdown($content, $guideFile);
|
$result = $this->parseMarkdown($content, $guideFile);
|
||||||
|
|
||||||
// Override title for guide
|
// Build breadcrumbs
|
||||||
$result['title'] = $this->t('manual') . ' - CodePress CMS';
|
$guideBase = '/' . $lang . '/guide';
|
||||||
$result['layout'] = $result['metadata']['layout'] ?? 'content';
|
$breadcrumbs = [['title' => $this->t('manual'), 'url' => $guideBase]];
|
||||||
|
if (!empty($pagePath)) {
|
||||||
|
$parts = explode('/', $pagePath);
|
||||||
|
$buildPath = '';
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$buildPath .= ($buildPath ? '/' : '') . $part;
|
||||||
|
$breadcrumbs[] = [
|
||||||
|
'title' => str_replace('-', ' ', ucfirst($part)),
|
||||||
|
'url' => $guideBase . '?page=' . $buildPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['title'] = ($result['metadata']['title'] ?? $this->t('manual')) . ' - CodePress CMS';
|
||||||
|
$result['layout'] = 'guide';
|
||||||
|
$result['metadata']['plugins'] = 'Navigation';
|
||||||
|
$result['guide_breadcrumbs'] = $breadcrumbs;
|
||||||
|
$result['guide_lang'] = $lang;
|
||||||
|
$result['guide_page'] = $pagePath;
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect user language from browser headers
|
|
||||||
*
|
|
||||||
* @return string Language code ('nl' or 'en')
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Generate directory listing page
|
* Generate directory listing page
|
||||||
*
|
*
|
||||||
@@ -1001,8 +1212,6 @@ class CodePressCMS {
|
|||||||
'content' => $content
|
'content' => $content
|
||||||
];
|
];
|
||||||
|
|
||||||
// Debug: ensure we're returning the right title
|
|
||||||
|
|
||||||
if (!is_dir($dirPath)) {
|
if (!is_dir($dirPath)) {
|
||||||
return [
|
return [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
@@ -1064,9 +1273,22 @@ class CodePressCMS {
|
|||||||
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
|
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for index.md in this directory for layout/plugins metadata
|
||||||
|
$indexFile = $dirPath . '/index.md';
|
||||||
|
$layout = 'full_content';
|
||||||
|
$metadata = [];
|
||||||
|
if (file_exists($indexFile)) {
|
||||||
|
$indexContent = file_get_contents($indexFile);
|
||||||
|
$parsed = $this->parseMetadata($indexContent);
|
||||||
|
$metadata = $parsed['metadata'];
|
||||||
|
$layout = $metadata['layout'] ?? 'full_content';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'content' => $content
|
'content' => $content,
|
||||||
|
'layout' => $layout,
|
||||||
|
'metadata' => $metadata,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1076,9 +1298,17 @@ class CodePressCMS {
|
|||||||
* @return array 404 page data
|
* @return array 404 page data
|
||||||
*/
|
*/
|
||||||
private function getError404() {
|
private function getError404() {
|
||||||
|
$staticFile = __DIR__ . '/../../../admin/static/404.html';
|
||||||
|
$body = file_exists($staticFile)
|
||||||
|
? file_get_contents($staticFile)
|
||||||
|
: '<h1>404 - ' . $this->t('page_not_found') . '</h1><p>' . $this->t('page_not_found_text') . '</p>';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => $this->t('page_not_found'),
|
'title' => $this->t('page_not_found'),
|
||||||
'content' => '<h1>404 - ' . $this->t('page_not_found') . '</h1><p>' . $this->t('page_not_found_text') . '</p>'
|
'content' => $body,
|
||||||
|
'layout' => 'full_content',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_404' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,6 +1331,11 @@ class CodePressCMS {
|
|||||||
$this->pluginManager->doAction('onPageLoad', $page);
|
$this->pluginManager->doAction('onPageLoad', $page);
|
||||||
$this->pluginManager->doAction('onBeforeRender');
|
$this->pluginManager->doAction('onBeforeRender');
|
||||||
|
|
||||||
|
// Set 404 status for not-found pages (rendered through the theme)
|
||||||
|
if (!empty($page['is_404'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
}
|
||||||
|
|
||||||
$menu = $this->getMenu();
|
$menu = $this->getMenu();
|
||||||
|
|
||||||
// Get homepage title
|
// Get homepage title
|
||||||
@@ -1117,8 +1352,9 @@ class CodePressCMS {
|
|||||||
$layout = $page['layout'] ?? 'sidebar-content';
|
$layout = $page['layout'] ?? 'sidebar-content';
|
||||||
|
|
||||||
// Determine if sidebar toggle should be shown
|
// Determine if sidebar toggle should be shown
|
||||||
|
$isGuidePage = isset($_GET['guide']) || ($page['layout'] ?? '') === 'guide';
|
||||||
$hasSidebar = $layout !== 'content' && !empty(trim($sidebarContent));
|
$hasSidebar = $layout !== 'content' && !empty(trim($sidebarContent));
|
||||||
$breadcrumb = $this->generateBreadcrumb($hasSidebar);
|
$breadcrumb = $this->generateBreadcrumb($hasSidebar, $isGuidePage ? $page : null);
|
||||||
|
|
||||||
// Prepare template data
|
// Prepare template data
|
||||||
$templateData = [
|
$templateData = [
|
||||||
@@ -1140,8 +1376,8 @@ class CodePressCMS {
|
|||||||
'is_guide_page' => isset($_GET['guide']),
|
'is_guide_page' => isset($_GET['guide']),
|
||||||
'lang_switch_url' => '',
|
'lang_switch_url' => '',
|
||||||
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
|
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
|
||||||
'author_website' => $this->config['author']['website'] ?? '#',
|
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
|
||||||
'author_git' => 'https://git.noorlander.info/E.Noorlander',
|
'author_git' => $this->config['author']['git'] ?? '',
|
||||||
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
|
'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',
|
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
|
||||||
'block_ai_bots' => !empty($this->config['security']['block_ai_bots']),
|
'block_ai_bots' => !empty($this->config['security']['block_ai_bots']),
|
||||||
@@ -1156,15 +1392,15 @@ class CodePressCMS {
|
|||||||
'nav_height' => $this->config['theme']['nav_height'] ?? '42',
|
'nav_height' => $this->config['theme']['nav_height'] ?? '42',
|
||||||
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
|
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
|
||||||
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
|
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
|
||||||
'background_image_css' => $this->getBackgroundImageCss(),
|
|
||||||
'background_image_opacity' => $this->getBackgroundImageOpacity(),
|
|
||||||
// Language
|
// Language
|
||||||
'current_lang' => $this->currentLanguage,
|
'current_lang' => $this->currentLanguage,
|
||||||
'current_lang_upper' => strtoupper($this->currentLanguage),
|
'current_lang_upper' => strtoupper($this->currentLanguage),
|
||||||
'current_page' => $_GET['page'] ?? $this->getEffectiveDefaultPage(),
|
'current_page' => $this->sanitizePageParam($_GET['page'] ?? $this->getEffectiveDefaultPage()),
|
||||||
'available_langs' => array_map(function($lang) {
|
'available_langs' => array_map(function($lang) {
|
||||||
$lang['is_current'] = $lang['code'] === $this->currentLanguage;
|
$lang['is_current'] = $lang['code'] === $this->currentLanguage;
|
||||||
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
|
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
|
||||||
|
// Sanitize page parameter to prevent XSS
|
||||||
|
$page = $this->sanitizePageParam($page);
|
||||||
$lang['url'] = '/' . $lang['code'] . ($page !== $this->getEffectiveDefaultPage() ? '/' . $page : '');
|
$lang['url'] = '/' . $lang['code'] . ($page !== $this->getEffectiveDefaultPage() ? '/' . $page : '');
|
||||||
return $lang;
|
return $lang;
|
||||||
}, $this->getAvailableLanguages()),
|
}, $this->getAvailableLanguages()),
|
||||||
@@ -1216,33 +1452,32 @@ class CodePressCMS {
|
|||||||
// Don't show site title link on guide page
|
// Don't show site title link on guide page
|
||||||
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
|
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
|
||||||
|
|
||||||
// Load and render all templates with data
|
// Pass guide-specific data to template
|
||||||
$layoutTemplate = file_get_contents($this->config['templates_dir'] . '/layout.mustache');
|
if (isset($page['guide_breadcrumbs'])) {
|
||||||
$headerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/header.mustache');
|
$templateData['guide_breadcrumbs'] = $page['guide_breadcrumbs'];
|
||||||
$navigationTemplate = file_get_contents($this->config['templates_dir'] . '/assets/navigation.mustache');
|
$templateData['guide_lang'] = $page['guide_lang'] ?? $this->currentLanguage;
|
||||||
$footerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache');
|
$templateData['guide_page'] = $page['guide_page'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
// Determine content type and load appropriate template
|
// Map legacy frontmatter layout values to theme template keys
|
||||||
$contentType = $this->getContentType($page);
|
$layoutKey = $this->mapLayoutToThemeKey($layout);
|
||||||
$contentTemplateFile = $this->config['templates_dir'] . '/' . $contentType . '_content.mustache';
|
|
||||||
$contentTemplate = file_exists($contentTemplateFile) ? file_get_contents($contentTemplateFile) : '<div class="content">{{{content}}}</div>';
|
|
||||||
|
|
||||||
|
// Add theme asset URLs to template data
|
||||||
|
$themeManager = new ThemeManager($this->config);
|
||||||
|
$templateData['theme_title'] = $themeManager->getTitle();
|
||||||
|
$templateData['theme_css_url'] = $themeManager->getCssUrl();
|
||||||
|
$templateData['theme_js_url'] = $themeManager->getJsUrl();
|
||||||
|
$templateData['theme_config'] = $themeManager->getConfig();
|
||||||
|
$templateData['theme_base_url'] = '/themes/' . basename($themeManager->getThemeDir());
|
||||||
|
$templateData['theme_css_files'] = $themeManager->getCssFiles();
|
||||||
|
$templateData['theme_js_files'] = $themeManager->getJsFiles();
|
||||||
|
$templateData['theme_favicon'] = $themeManager->getFaviconUrl();
|
||||||
|
|
||||||
|
// Plugin CSS (loaded after theme CSS so theme can override)
|
||||||
|
$templateData['plugin_css_urls'] = $this->pluginManager->getPluginCssUrls();
|
||||||
|
|
||||||
// Render all templates with data
|
// Render the page through the active theme
|
||||||
$renderedHeader = SimpleTemplate::render($headerTemplate, $templateData);
|
$renderedLayout = $themeManager->render($layoutKey, $templateData);
|
||||||
$renderedNavigation = SimpleTemplate::render($navigationTemplate, $templateData);
|
|
||||||
$renderedFooter = SimpleTemplate::render($footerTemplate, $templateData);
|
|
||||||
$renderedContent = SimpleTemplate::render($contentTemplate, $templateData);
|
|
||||||
|
|
||||||
// Replace partials in layout
|
|
||||||
$finalTemplate = str_replace('{{>header}}', $renderedHeader, $layoutTemplate);
|
|
||||||
$finalTemplate = str_replace('{{>navigation}}', $renderedNavigation, $finalTemplate);
|
|
||||||
$finalTemplate = str_replace('{{>footer}}', $renderedFooter, $finalTemplate);
|
|
||||||
$finalTemplate = str_replace('{{>content_template}}', $renderedContent, $finalTemplate);
|
|
||||||
|
|
||||||
// Render the final layout with all template data
|
|
||||||
$renderedLayout = SimpleTemplate::render($finalTemplate, $templateData);
|
|
||||||
|
|
||||||
echo $renderedLayout;
|
echo $renderedLayout;
|
||||||
|
|
||||||
@@ -1253,15 +1488,46 @@ class CodePressCMS {
|
|||||||
* Generate breadcrumb navigation HTML
|
* Generate breadcrumb navigation HTML
|
||||||
*
|
*
|
||||||
* @param bool $hasSidebar Whether sidebar content exists and should show toggle
|
* @param bool $hasSidebar Whether sidebar content exists and should show toggle
|
||||||
|
* @param array|null $guidePage Guide page data (when on a guide page)
|
||||||
* @return string Breadcrumb HTML
|
* @return string Breadcrumb HTML
|
||||||
*/
|
*/
|
||||||
public function generateBreadcrumb($hasSidebar = true) {
|
public function generateBreadcrumb($hasSidebar = true, $guidePage = null) {
|
||||||
// Sidebar toggle button (shown before home icon in breadcrumb)
|
// Sidebar toggle button (shown before home icon in breadcrumb)
|
||||||
$sidebarToggle = '';
|
$sidebarToggle = '';
|
||||||
if ($hasSidebar) {
|
if ($hasSidebar) {
|
||||||
$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>';
|
$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>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guide page breadcrumb: Home > Handleiding > [page parts]
|
||||||
|
if ($guidePage !== null) {
|
||||||
|
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
|
||||||
|
$breadcrumb .= $sidebarToggle;
|
||||||
|
$breadcrumb .= '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>';
|
||||||
|
$breadcrumb .= '<li class="breadcrumb-item"> > </li>';
|
||||||
|
$guidePagePath = $guidePage['guide_page'] ?? '';
|
||||||
|
$guideLang = $guidePage['guide_lang'] ?? $this->currentLanguage;
|
||||||
|
$guideBase = '/' . $guideLang . '/guide';
|
||||||
|
if (empty($guidePagePath)) {
|
||||||
|
$breadcrumb .= '<li class="breadcrumb-item active">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</li>';
|
||||||
|
} else {
|
||||||
|
$breadcrumb .= '<li class="breadcrumb-item"><a href="' . htmlspecialchars($guideBase, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</a></li>';
|
||||||
|
$parts = explode('/', $guidePagePath);
|
||||||
|
$buildPath = '';
|
||||||
|
foreach ($parts as $i => $part) {
|
||||||
|
$buildPath .= ($buildPath ? '/' : '') . $part;
|
||||||
|
$title = htmlspecialchars(str_replace('-', ' ', ucfirst($part)), ENT_QUOTES, 'UTF-8');
|
||||||
|
$url = htmlspecialchars($guideBase . '?page=' . $buildPath, ENT_QUOTES, 'UTF-8');
|
||||||
|
if ($i === count($parts) - 1) {
|
||||||
|
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>';
|
||||||
|
} else {
|
||||||
|
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="' . $url . '">' . $title . '</a></li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$breadcrumb .= '</ol></nav>';
|
||||||
|
return $breadcrumb;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($_GET['search'])) {
|
if (isset($_GET['search'])) {
|
||||||
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li><li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $this->t('search') . '</li></ol></nav>';
|
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item"><a href="/' . $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>';
|
||||||
}
|
}
|
||||||
@@ -1270,18 +1536,16 @@ class CodePressCMS {
|
|||||||
$page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
|
$page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
|
||||||
$page = preg_replace('/\.[^.]+$/', '', $page);
|
$page = preg_replace('/\.[^.]+$/', '', $page);
|
||||||
|
|
||||||
// Convert page to clean URL format for breadcrumb
|
$isHomepage = ($page === $this->getEffectiveDefaultPage());
|
||||||
if ($page === $this->getEffectiveDefaultPage()) {
|
$homeUrl = '/' . $this->currentLanguage;
|
||||||
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">';
|
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
|
||||||
|
|
||||||
// Start with sidebar toggle, then home icon linking to default page (root)
|
|
||||||
$breadcrumb .= $sidebarToggle;
|
$breadcrumb .= $sidebarToggle;
|
||||||
$breadcrumb .= '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>';
|
|
||||||
|
|
||||||
// Split page path and build breadcrumb items
|
// Home icon: clickable link to language root (always, unless we're on the root itself)
|
||||||
|
$breadcrumb .= '<li class="breadcrumb-item"><a href="' . $homeUrl . '"><i class="bi bi-house"></i></a></li>';
|
||||||
|
|
||||||
|
// Split page path and build breadcrumb items (handles subdirectories dynamically)
|
||||||
$parts = explode('/', $page);
|
$parts = explode('/', $page);
|
||||||
$currentPath = '';
|
$currentPath = '';
|
||||||
|
|
||||||
@@ -1321,7 +1585,7 @@ class CodePressCMS {
|
|||||||
$isExpanded = $this->folderContainsActivePage($item['children']);
|
$isExpanded = $this->folderContainsActivePage($item['children']);
|
||||||
|
|
||||||
if ($level === 0) {
|
if ($level === 0) {
|
||||||
// Root level folders
|
// Root level folders - Bootstrap dropdown
|
||||||
$html .= '<li class="nav-item dropdown">';
|
$html .= '<li class="nav-item dropdown">';
|
||||||
$html .= '<a class="nav-link dropdown-toggle" href="#" id="' . $folderId . '" role="button" data-bs-toggle="dropdown" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">';
|
$html .= '<a class="nav-link dropdown-toggle" href="#" id="' . $folderId . '" role="button" data-bs-toggle="dropdown" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">';
|
||||||
$html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-down"></i>';
|
$html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-down"></i>';
|
||||||
@@ -1331,12 +1595,12 @@ class CodePressCMS {
|
|||||||
$html .= '</ul>';
|
$html .= '</ul>';
|
||||||
$html .= '</li>';
|
$html .= '</li>';
|
||||||
} else {
|
} else {
|
||||||
// Nested folders in dropdown
|
// Nested folders - CSS hover submenu (unlimited depth)
|
||||||
$html .= '<li class="dropdown-submenu">';
|
$html .= '<li class="dropdown-submenu' . ($isExpanded ? ' show' : '') . '">';
|
||||||
$html .= '<a class="dropdown-item dropdown-toggle" href="#" id="' . $folderId . '">';
|
$html .= '<a class="dropdown-item dropdown-toggle" href="#" id="' . $folderId . '" role="button" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">';
|
||||||
$html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-down"></i>';
|
$html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-right"></i>';
|
||||||
$html .= '</a>';
|
$html .= '</a>';
|
||||||
$html .= '<ul class="dropdown-menu" aria-labelledby="' . $folderId . '">';
|
$html .= '<ul class="dropdown-menu' . ($isExpanded ? ' show' : '') . '" aria-labelledby="' . $folderId . '">';
|
||||||
$html .= $this->renderMenu($item['children'], $level + 1);
|
$html .= $this->renderMenu($item['children'], $level + 1);
|
||||||
$html .= '</ul>';
|
$html .= '</ul>';
|
||||||
$html .= '</li>';
|
$html .= '</li>';
|
||||||
@@ -1372,32 +1636,22 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine content type for current page
|
* Map a frontmatter layout value to a theme template key.
|
||||||
*
|
*
|
||||||
* @param array $page Page data
|
* Legacy values are translated to the new theme keys. Unknown values
|
||||||
* @return string Content type (markdown, php, html)
|
* are passed through so ThemeManager can fall back to default_layout.
|
||||||
|
*
|
||||||
|
* @param string $layout Layout value from page metadata
|
||||||
|
* @return string Theme template key
|
||||||
*/
|
*/
|
||||||
private function getContentType($page) {
|
private function mapLayoutToThemeKey(string $layout): string {
|
||||||
// Try to determine content type from page request
|
return match ($layout) {
|
||||||
$pagePath = $_GET['page'] ?? $this->getEffectiveDefaultPage();
|
'content' => 'full_content',
|
||||||
$pagePath = preg_replace('/\.[^.]+$/', '', $pagePath);
|
'sidebar-content', 'content-sidebar' => 'left_sidebar',
|
||||||
|
'content-sidebar-reverse' => 'right_sidebar',
|
||||||
$filePath = $this->config['content_dir'] . '/' . $pagePath;
|
'sidebar' => 'custom1',
|
||||||
|
default => $layout,
|
||||||
// Check for different file extensions
|
};
|
||||||
if (file_exists($filePath . '.md')) {
|
|
||||||
return 'markdown';
|
|
||||||
} elseif (file_exists($filePath . '.php')) {
|
|
||||||
return 'php';
|
|
||||||
} elseif (file_exists($filePath . '.html')) {
|
|
||||||
return 'html';
|
|
||||||
} elseif (file_exists($filePath)) {
|
|
||||||
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
|
|
||||||
return in_array($extension, ['md', 'php', 'html']) ? $extension : 'markdown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to markdown
|
|
||||||
return 'markdown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1540,24 +1794,6 @@ class CodePressCMS {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getBackgroundImageCss(): string
|
|
||||||
{
|
|
||||||
$bg = $this->config['theme']['background_image'] ?? '';
|
|
||||||
if (empty($bg)) {
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
if (str_starts_with($bg, 'http')) {
|
|
||||||
return 'url(' . $bg . ')';
|
|
||||||
}
|
|
||||||
return 'url(/themes/' . $bg . ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getBackgroundImageOpacity(): int
|
|
||||||
{
|
|
||||||
$opacity = intval($this->config['theme']['background_image_opacity'] ?? 100);
|
|
||||||
return max(0, min(100, $opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function processContent(string $content): string
|
private function processContent(string $content): string
|
||||||
{
|
{
|
||||||
return str_replace('-/assets/', '/-assets/', $content);
|
return str_replace('-/assets/', '/-assets/', $content);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContentBackup — backup and restore the content directory.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - ZIP backup of the entire content directory
|
||||||
|
* - Restore from an uploaded ZIP file
|
||||||
|
* - Optional git-based versioning (init / commit / log / restore)
|
||||||
|
*/
|
||||||
|
class ContentBackup
|
||||||
|
{
|
||||||
|
private string $contentDir;
|
||||||
|
private string $projectRoot;
|
||||||
|
private bool $gitAvailable;
|
||||||
|
|
||||||
|
public function __construct(string $contentDir, string $projectRoot = '')
|
||||||
|
{
|
||||||
|
$this->contentDir = rtrim($contentDir, '/');
|
||||||
|
$this->projectRoot = $projectRoot !== '' ? rtrim($projectRoot, '/') : dirname(__DIR__, 3);
|
||||||
|
$this->gitAvailable = $this->detectGit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether git is available and the content dir is inside a git repo.
|
||||||
|
*/
|
||||||
|
public function isGitAvailable(): bool
|
||||||
|
{
|
||||||
|
return $this->gitAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ZIP backup of the content directory.
|
||||||
|
*
|
||||||
|
* @param string $outputPath Where to write the ZIP file
|
||||||
|
* @return bool True on success
|
||||||
|
*/
|
||||||
|
public function createZipBackup(string $outputPath): bool
|
||||||
|
{
|
||||||
|
if (!class_exists('ZipArchive')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_dir($this->contentDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addDirToZip($zip, $this->contentDir, 'content');
|
||||||
|
return $zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore content from a ZIP file.
|
||||||
|
*
|
||||||
|
* @param string $zipPath Path to the uploaded ZIP file
|
||||||
|
* @return array ['success' => bool, 'message' => string]
|
||||||
|
*/
|
||||||
|
public function restoreFromZip(string $zipPath): array
|
||||||
|
{
|
||||||
|
if (!class_exists('ZipArchive')) {
|
||||||
|
return ['success' => false, 'message' => 'ZipArchive niet beschikbaar.'];
|
||||||
|
}
|
||||||
|
if (!file_exists($zipPath)) {
|
||||||
|
return ['success' => false, 'message' => 'ZIP bestand niet gevonden.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($zipPath) !== true) {
|
||||||
|
return ['success' => false, 'message' => 'Kan ZIP bestand niet openen.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempDir = $this->projectRoot . '/var/tmp/restore_' . date('YmdHis');
|
||||||
|
if (!@mkdir($tempDir, 0755, true)) {
|
||||||
|
$zip->close();
|
||||||
|
return ['success' => false, 'message' => 'Kan tijdelijke map niet aanmaken.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->extractTo($tempDir);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
// Determine source: either $tempDir/content/ or $tempDir/ itself
|
||||||
|
$sourceDir = is_dir($tempDir . '/content') ? $tempDir . '/content' : $tempDir;
|
||||||
|
|
||||||
|
// Backup current content before overwriting
|
||||||
|
$backupSubDir = $this->contentDir . '.bak.' . date('YmdHis');
|
||||||
|
if (is_dir($this->contentDir)) {
|
||||||
|
rename($this->contentDir, $backupSubDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@mkdir($this->contentDir, 0755, true)) {
|
||||||
|
// Restore old content if mkdir fails
|
||||||
|
if (is_dir($backupSubDir)) {
|
||||||
|
rename($backupSubDir, $this->contentDir);
|
||||||
|
}
|
||||||
|
$this->removeDir($tempDir);
|
||||||
|
return ['success' => false, 'message' => 'Kan content map niet aanmaken.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->copyDir($sourceDir, $this->contentDir);
|
||||||
|
|
||||||
|
// Clean up temp dir
|
||||||
|
$this->removeDir($tempDir);
|
||||||
|
|
||||||
|
// Remove old backup (keep last one)
|
||||||
|
$this->cleanupOldBackups();
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => 'Content succesvol hersteld.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize git in the content directory.
|
||||||
|
*
|
||||||
|
* @return array ['success' => bool, 'message' => string]
|
||||||
|
*/
|
||||||
|
public function gitInit(): array
|
||||||
|
{
|
||||||
|
if (!$this->gitAvailable) {
|
||||||
|
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->execGitCapture(['init'], $this->contentDir);
|
||||||
|
if ($result['exit'] !== 0) {
|
||||||
|
return ['success' => false, 'message' => 'Git init mislukt: ' . trim($result['error'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure a default identity so commits work without global git config
|
||||||
|
$this->execGitCapture(['config', 'user.email', 'content@codepress.local'], $this->contentDir);
|
||||||
|
$this->execGitCapture(['config', 'user.name', 'CodePress CMS'], $this->contentDir);
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => 'Git repository geïnitialiseerd in content/.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit all changes in the content directory.
|
||||||
|
*
|
||||||
|
* @param string $message Commit message
|
||||||
|
* @return array ['success' => bool, 'message' => string]
|
||||||
|
*/
|
||||||
|
public function gitCommit(string $message): array
|
||||||
|
{
|
||||||
|
if (!$this->gitAvailable) {
|
||||||
|
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasGitRepo()) {
|
||||||
|
return ['success' => false, 'message' => 'Geen git repository in content/. Voer eerst git init uit.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = trim($message) !== '' ? $message : 'Content update ' . date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
// Add all files
|
||||||
|
$addResult = $this->execGitCapture(['add', '-A'], $this->contentDir);
|
||||||
|
if ($addResult['exit'] !== 0) {
|
||||||
|
return ['success' => false, 'message' => 'Git add mislukt: ' . trim($addResult['error'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are changes to commit
|
||||||
|
$statusResult = $this->execGitCapture(['status', '--porcelain'], $this->contentDir);
|
||||||
|
if (trim($statusResult['output']) === '') {
|
||||||
|
return ['success' => true, 'message' => 'Geen wijzigingen om te committen.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$commitResult = $this->execGitCapture(['commit', '-m', $msg], $this->contentDir);
|
||||||
|
if ($commitResult['exit'] !== 0) {
|
||||||
|
return ['success' => false, 'message' => 'Git commit mislukt: ' . trim($commitResult['error'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => 'Wijzigingen gecommit: ' . $msg];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the git log for the content directory.
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum number of entries
|
||||||
|
* @return array ['success' => bool, 'commits' => array, 'message' => string]
|
||||||
|
*/
|
||||||
|
public function gitLog(int $limit = 20): array
|
||||||
|
{
|
||||||
|
if (!$this->gitAvailable) {
|
||||||
|
return ['success' => false, 'commits' => [], 'message' => 'Git is niet beschikbaar.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasGitRepo()) {
|
||||||
|
return ['success' => false, 'commits' => [], 'message' => 'Geen git repository.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->execGitCapture(
|
||||||
|
['log', '--pretty=format:%H|%h|%ai|%s', '-' . (string)$limit],
|
||||||
|
$this->contentDir
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['exit'] !== 0) {
|
||||||
|
// No commits yet is not an error in our context
|
||||||
|
if (str_contains($result['error'], 'does not have any commits')) {
|
||||||
|
return ['success' => true, 'commits' => [], 'message' => ''];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'commits' => [], 'message' => 'Kan git log niet uitlezen: ' . trim($result['error'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
$commits = [];
|
||||||
|
$lines = explode("\n", trim($result['output']));
|
||||||
|
if ($lines !== ['']) {
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$parts = explode('|', $line, 4);
|
||||||
|
if (count($parts) === 4) {
|
||||||
|
$commits[] = [
|
||||||
|
'hash' => $parts[0],
|
||||||
|
'short' => $parts[1],
|
||||||
|
'date' => $parts[2],
|
||||||
|
'message' => $parts[3],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'commits' => $commits, 'message' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore content to a specific git commit.
|
||||||
|
*
|
||||||
|
* @param string $commitHash Commit hash to restore to
|
||||||
|
* @return array ['success' => bool, 'message' => string]
|
||||||
|
*/
|
||||||
|
public function gitRestore(string $commitHash): array
|
||||||
|
{
|
||||||
|
if (!$this->gitAvailable) {
|
||||||
|
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasGitRepo()) {
|
||||||
|
return ['success' => false, 'message' => 'Geen git repository.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = preg_replace('/[^a-f0-9]/', '', $commitHash);
|
||||||
|
if ($hash === '') {
|
||||||
|
return ['success' => false, 'message' => 'Ongeldige commit hash.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkout <hash> -- . restores files from that commit into the working tree
|
||||||
|
$result = $this->execGitCapture(['checkout', $hash, '--', '.'], $this->contentDir);
|
||||||
|
if ($result['exit'] !== 0) {
|
||||||
|
return ['success' => false, 'message' => 'Git restore mislukt: ' . trim($result['error'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => 'Content hersteld naar commit ' . substr($hash, 0, 7)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the content directory has a git repository.
|
||||||
|
*/
|
||||||
|
public function hasGitRepo(): bool
|
||||||
|
{
|
||||||
|
return is_dir($this->contentDir . '/.git');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private function detectGit(): bool
|
||||||
|
{
|
||||||
|
$result = $this->execGitCapture(['--version']);
|
||||||
|
return $result['exit'] === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a git command and capture output, error, and exit code separately.
|
||||||
|
*
|
||||||
|
* @return array ['output' => string, 'error' => string, 'exit' => int]
|
||||||
|
*/
|
||||||
|
private function execGitCapture(array $args, ?string $cwd = null): array
|
||||||
|
{
|
||||||
|
$escaped = array_map('escapeshellarg', $args);
|
||||||
|
$command = 'git ' . implode(' ', $escaped);
|
||||||
|
|
||||||
|
$descriptors = [
|
||||||
|
0 => ['pipe', 'r'], // stdin
|
||||||
|
1 => ['pipe', 'w'], // stdout
|
||||||
|
2 => ['pipe', 'w'], // stderr
|
||||||
|
];
|
||||||
|
|
||||||
|
$process = proc_open($command, $descriptors, $pipes, $cwd ?? null);
|
||||||
|
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
return ['output' => '', 'error' => 'Kan git proces niet starten.', 'exit' => -1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = stream_get_contents($pipes[1]);
|
||||||
|
$error = stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[0]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
|
||||||
|
$exitCode = proc_close($process);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'output' => $output !== false ? $output : '',
|
||||||
|
'error' => $error !== false ? $error : '',
|
||||||
|
'exit' => $exitCode,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
|
||||||
|
{
|
||||||
|
$entries = scandir($dir);
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $dir . '/' . $entry;
|
||||||
|
$zipPath = $prefix . '/' . $entry;
|
||||||
|
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$zip->addEmptyDir($zipPath);
|
||||||
|
$this->addDirToZip($zip, $path, $zipPath);
|
||||||
|
} elseif (is_file($path)) {
|
||||||
|
$zip->addFile($path, $zipPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copyDir(string $src, string $dst): void
|
||||||
|
{
|
||||||
|
if (!is_dir($src)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($dst)) {
|
||||||
|
@mkdir($dst, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = scandir($src);
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$srcPath = $src . '/' . $entry;
|
||||||
|
$dstPath = $dst . '/' . $entry;
|
||||||
|
|
||||||
|
if (is_dir($srcPath)) {
|
||||||
|
$this->copyDir($srcPath, $dstPath);
|
||||||
|
} elseif (is_file($srcPath)) {
|
||||||
|
copy($srcPath, $dstPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = scandir($dir);
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $dir . '/' . $entry;
|
||||||
|
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$this->removeDir($path);
|
||||||
|
} else {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupOldBackups(): void
|
||||||
|
{
|
||||||
|
$parentDir = dirname($this->contentDir);
|
||||||
|
$baseName = basename($this->contentDir);
|
||||||
|
$pattern = $parentDir . '/' . $baseName . '.bak.*';
|
||||||
|
|
||||||
|
$backups = glob($pattern);
|
||||||
|
if ($backups === false || count($backups) <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time (oldest first)
|
||||||
|
usort($backups, function ($a, $b) {
|
||||||
|
return filemtime($a) - filemtime($b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove all but the last backup
|
||||||
|
$toRemove = array_slice($backups, 0, count($backups) - 1);
|
||||||
|
foreach ($toRemove as $backup) {
|
||||||
|
$this->removeDir($backup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class ContentSecurityPolicy {
|
|
||||||
private array $directives = [];
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
$this->directives = [
|
|
||||||
'default-src' => ["'self'"],
|
|
||||||
'script-src' => ["'self'", "'unsafe-inline'"],
|
|
||||||
'style-src' => ["'self'", "'unsafe-inline'"],
|
|
||||||
'img-src' => ["'self'", 'data:', 'https:'],
|
|
||||||
'font-src' => ["'self'"],
|
|
||||||
'connect-src' => ["'self'"],
|
|
||||||
'media-src' => ["'self'"],
|
|
||||||
'object-src' => ["'none'"],
|
|
||||||
'frame-src' => ["'none'"],
|
|
||||||
'base-uri' => ["'self'"],
|
|
||||||
'form-action' => ["'self'"]
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addDirective(string $name, array $values): void {
|
|
||||||
if (!isset($this->directives[$name])) {
|
|
||||||
$this->directives[$name] = [];
|
|
||||||
}
|
|
||||||
$this->directives[$name] = array_merge($this->directives[$name], $values);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeDirective(string $name): void {
|
|
||||||
unset($this->directives[$name]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDirective(string $name, array $values): void {
|
|
||||||
$this->directives[$name] = $values;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toHeader(): string {
|
|
||||||
$parts = [];
|
|
||||||
foreach ($this->directives as $directive => $values) {
|
|
||||||
if (!empty($values)) {
|
|
||||||
$parts[] = $directive . ' ' . implode(' ', $values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return implode('; ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toMetaTag(): string {
|
|
||||||
return '<meta http-equiv="Content-Security-Policy" content="' . htmlspecialchars($this->toHeader()) . '">';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EnhancedSecurity - Advanced Security with WCAG Compliance
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Advanced XSS protection with DOMPurify integration
|
|
||||||
* - Content Security Policy headers
|
|
||||||
* - Input validation and sanitization
|
|
||||||
* - SQL injection prevention
|
|
||||||
* - File upload security
|
|
||||||
* - Rate limiting
|
|
||||||
* - CSRF protection
|
|
||||||
* - WCAG 2.1 AA compliant security
|
|
||||||
*/
|
|
||||||
class EnhancedSecurity {
|
|
||||||
private $config;
|
|
||||||
private $cspHeaders;
|
|
||||||
private $allowedTags;
|
|
||||||
private $allowedAttributes;
|
|
||||||
|
|
||||||
public function __construct($config = []) {
|
|
||||||
$this->config = $config;
|
|
||||||
$this->initializeSecurity();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize security settings
|
|
||||||
*/
|
|
||||||
private function initializeSecurity() {
|
|
||||||
// WCAG compliant CSP headers
|
|
||||||
$this->cspHeaders = [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Required for accessibility
|
|
||||||
"style-src 'self' 'unsafe-inline'", // Required for accessibility
|
|
||||||
"img-src 'self' data: https:",
|
|
||||||
"font-src 'self' data:",
|
|
||||||
"connect-src 'self'",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"form-action 'self'"
|
|
||||||
];
|
|
||||||
|
|
||||||
// WCAG compliant allowed tags
|
|
||||||
$this->allowedTags = [
|
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
||||||
'p', 'br', 'strong', 'em', 'u', 'i', 'b',
|
|
||||||
'a', 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
||||||
'div', 'span', 'section', 'article', 'aside',
|
|
||||||
'header', 'footer', 'nav', 'main',
|
|
||||||
'img', 'picture', 'source',
|
|
||||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
||||||
'blockquote', 'code', 'pre',
|
|
||||||
'hr', 'small', 'sub', 'sup',
|
|
||||||
'button', 'input', 'label', 'select', 'option', 'textarea',
|
|
||||||
'form', 'fieldset', 'legend',
|
|
||||||
'time', 'address', 'abbr'
|
|
||||||
];
|
|
||||||
|
|
||||||
// WCAG compliant allowed attributes
|
|
||||||
$this->allowedAttributes = [
|
|
||||||
'href', 'src', 'alt', 'title', 'id', 'class',
|
|
||||||
'role', 'aria-label', 'aria-labelledby', 'aria-describedby',
|
|
||||||
'aria-expanded', 'aria-pressed', 'aria-current', 'aria-hidden',
|
|
||||||
'aria-live', 'aria-atomic', 'aria-busy', 'aria-relevant',
|
|
||||||
'aria-controls', 'aria-owns', 'aria-flowto', 'aria-errormessage',
|
|
||||||
'aria-invalid', 'aria-required', 'aria-disabled', 'aria-readonly',
|
|
||||||
'aria-haspopup', 'aria-orientation', 'aria-sort', 'aria-selected',
|
|
||||||
'aria-setsize', 'aria-posinset', 'aria-level', 'aria-valuemin',
|
|
||||||
'aria-valuemax', 'aria-valuenow', 'aria-valuetext',
|
|
||||||
'tabindex', 'accesskey', 'lang', 'dir', 'translate',
|
|
||||||
'for', 'name', 'type', 'value', 'placeholder', 'required',
|
|
||||||
'disabled', 'readonly', 'checked', 'selected', 'multiple',
|
|
||||||
'size', 'maxlength', 'minlength', 'min', 'max', 'step',
|
|
||||||
'pattern', 'autocomplete', 'autocorrect', 'autocapitalize',
|
|
||||||
'spellcheck', 'draggable', 'dropzone', 'data-*',
|
|
||||||
'width', 'height', 'style', 'loading', 'decoding',
|
|
||||||
'crossorigin', 'referrerpolicy', 'integrity', 'sizes', 'srcset',
|
|
||||||
'media', 'scope', 'colspan', 'rowspan', 'headers',
|
|
||||||
'datetime', 'pubdate', 'cite', 'rel', 'target',
|
|
||||||
'download', 'hreflang', 'type', 'method', 'action', 'enctype',
|
|
||||||
'novalidate', 'accept', 'accept-charset', 'autocomplete', 'target',
|
|
||||||
'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate',
|
|
||||||
'formtarget', 'list', 'multiple', 'pattern', 'placeholder',
|
|
||||||
'readonly', 'required', 'size', 'maxlength', 'minlength',
|
|
||||||
'min', 'max', 'step', 'autocomplete', 'autofocus', 'dirname',
|
|
||||||
'inputmode', 'wrap', 'rows', 'cols', 'role', 'aria-label',
|
|
||||||
'aria-labelledby', 'aria-describedby', 'aria-expanded', 'aria-pressed',
|
|
||||||
'aria-current', 'aria-hidden', 'aria-live', 'aria-atomic',
|
|
||||||
'aria-busy', 'aria-relevant', 'aria-controls', 'aria-owns',
|
|
||||||
'aria-flowto', 'aria-errormessage', 'aria-invalid', 'aria-required',
|
|
||||||
'aria-disabled', 'aria-readonly', 'aria-haspopup', 'aria-orientation',
|
|
||||||
'aria-sort', 'aria-selected', 'aria-setsize', 'aria-posinset',
|
|
||||||
'aria-level', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow',
|
|
||||||
'aria-valuetext', 'tabindex', 'accesskey', 'lang', 'dir', 'translate'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set security headers
|
|
||||||
*/
|
|
||||||
public function setSecurityHeaders() {
|
|
||||||
// Content Security Policy
|
|
||||||
header('Content-Security-Policy: ' . implode('; ', $this->cspHeaders));
|
|
||||||
|
|
||||||
// Other security headers
|
|
||||||
header('X-Frame-Options: DENY');
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
|
||||||
header('X-XSS-Protection: 1; mode=block');
|
|
||||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
||||||
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
|
|
||||||
|
|
||||||
// WCAG compliant headers
|
|
||||||
header('Feature-Policy: camera \'none\'; microphone \'none\'; geolocation \'none\'');
|
|
||||||
header('Access-Control-Allow-Origin: \'self\'');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advanced XSS protection with accessibility preservation
|
|
||||||
*
|
|
||||||
* @param string $input Input to sanitize
|
|
||||||
* @param string $type Input type (html, text, url, etc.)
|
|
||||||
* @return string Sanitized input
|
|
||||||
*/
|
|
||||||
public function sanitizeInput($input, $type = 'text') {
|
|
||||||
if (empty($input)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ($type) {
|
|
||||||
case 'html':
|
|
||||||
return $this->sanitizeHTML($input);
|
|
||||||
case 'url':
|
|
||||||
return $this->sanitizeURL($input);
|
|
||||||
case 'email':
|
|
||||||
return $this->sanitizeEmail($input);
|
|
||||||
case 'filename':
|
|
||||||
return $this->sanitizeFilename($input);
|
|
||||||
case 'search':
|
|
||||||
return $this->sanitizeSearch($input);
|
|
||||||
default:
|
|
||||||
return $this->sanitizeText($input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize HTML content while preserving accessibility
|
|
||||||
*
|
|
||||||
* @param string $html HTML content
|
|
||||||
* @return string Sanitized HTML
|
|
||||||
*/
|
|
||||||
private function sanitizeHTML($html) {
|
|
||||||
// Remove dangerous protocols
|
|
||||||
$html = preg_replace('/(javascript|vbscript|data|file):/i', '', $html);
|
|
||||||
|
|
||||||
// Remove script tags and content
|
|
||||||
$html = preg_replace('/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/mi', '', $html);
|
|
||||||
|
|
||||||
// Remove dangerous attributes
|
|
||||||
$html = preg_replace('/\s*(on\w+|style|expression)\s*=\s*["\'][^"\']*["\']/', '', $html);
|
|
||||||
|
|
||||||
// Remove HTML comments
|
|
||||||
$html = preg_replace('/<!--.*?-->/s', '', $html);
|
|
||||||
|
|
||||||
// Sanitize with allowed tags and attributes
|
|
||||||
$html = $this->filterHTML($html);
|
|
||||||
|
|
||||||
// Ensure accessibility attributes are preserved
|
|
||||||
$html = $this->ensureAccessibilityAttributes($html);
|
|
||||||
|
|
||||||
return trim($html);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter HTML with allowed tags and attributes
|
|
||||||
*
|
|
||||||
* @param string $html HTML content
|
|
||||||
* @return string Filtered HTML
|
|
||||||
*/
|
|
||||||
private function filterHTML($html) {
|
|
||||||
// Simple HTML filter (in production, use proper HTML parser)
|
|
||||||
$allowedTagsString = implode('|', $this->allowedTags);
|
|
||||||
|
|
||||||
// Remove disallowed tags
|
|
||||||
$html = preg_replace('/<\/?(?!' . $allowedTagsString . ')([a-z][a-z0-9]*)\b[^>]*>/i', '', $html);
|
|
||||||
|
|
||||||
// Remove dangerous attributes from allowed tags
|
|
||||||
foreach ($this->allowedTags as $tag) {
|
|
||||||
$html = preg_replace('/<' . $tag . '\b[^>]*?\s+(on\w+|style|expression)\s*=\s*["\'][^"\']*["\'][^>]*>/i', '<' . $tag . '>', $html);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure accessibility attributes are present
|
|
||||||
*
|
|
||||||
* @param string $html HTML content
|
|
||||||
* @return string HTML with accessibility attributes
|
|
||||||
*/
|
|
||||||
private function ensureAccessibilityAttributes($html) {
|
|
||||||
// Ensure images have alt text
|
|
||||||
$html = preg_replace('/<img(?![^>]*alt=)/i', '<img alt=""', $html);
|
|
||||||
|
|
||||||
// Ensure links have accessible labels
|
|
||||||
$html = preg_replace('/<a\s+href=["\'][^"\']*["\'](?![^>]*>.*?<\/a>)/i', '<a aria-label="Link"', $html);
|
|
||||||
|
|
||||||
// Ensure form inputs have labels
|
|
||||||
$html = preg_replace('/<input(?![^>]*id=)/i', '<input id="input-' . uniqid() . '"', $html);
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize text input
|
|
||||||
*
|
|
||||||
* @param string $text Text input
|
|
||||||
* @return string Sanitized text
|
|
||||||
*/
|
|
||||||
private function sanitizeText($text) {
|
|
||||||
// Remove null bytes
|
|
||||||
$text = str_replace("\0", '', $text);
|
|
||||||
|
|
||||||
// Normalize whitespace
|
|
||||||
$text = preg_replace('/\s+/', ' ', $text);
|
|
||||||
|
|
||||||
// Remove control characters except newlines and tabs
|
|
||||||
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
|
|
||||||
|
|
||||||
// HTML encode
|
|
||||||
return htmlspecialchars(trim($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize URL input
|
|
||||||
*
|
|
||||||
* @param string $url URL input
|
|
||||||
* @return string Sanitized URL
|
|
||||||
*/
|
|
||||||
private function sanitizeURL($url) {
|
|
||||||
// Remove dangerous protocols
|
|
||||||
$url = preg_replace('/^(javascript|vbscript|data|file):/i', '', $url);
|
|
||||||
|
|
||||||
// Validate URL format
|
|
||||||
if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/') && !str_starts_with($url, '#')) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return htmlspecialchars($url, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize email input
|
|
||||||
*
|
|
||||||
* @param string $email Email input
|
|
||||||
* @return string Sanitized email
|
|
||||||
*/
|
|
||||||
private function sanitizeEmail($email) {
|
|
||||||
$email = filter_var($email, FILTER_SANITIZE_EMAIL);
|
|
||||||
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize filename input
|
|
||||||
*
|
|
||||||
* @param string $filename Filename input
|
|
||||||
* @return string Sanitized filename
|
|
||||||
*/
|
|
||||||
private function sanitizeFilename($filename) {
|
|
||||||
// Remove path traversal
|
|
||||||
$filename = str_replace(['../', '..\\', '..'], '', $filename);
|
|
||||||
|
|
||||||
// Remove dangerous characters
|
|
||||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
|
|
||||||
|
|
||||||
// Limit length
|
|
||||||
return substr($filename, 0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize search input
|
|
||||||
*
|
|
||||||
* @param string $search Search input
|
|
||||||
* @return string Sanitized search
|
|
||||||
*/
|
|
||||||
private function sanitizeSearch($search) {
|
|
||||||
// Allow search characters but remove dangerous ones
|
|
||||||
$search = preg_replace('/[<>"\']/', '', $search);
|
|
||||||
|
|
||||||
// Limit length
|
|
||||||
return substr(trim($search), 0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate CSRF token
|
|
||||||
*
|
|
||||||
* @param string $token CSRF token to validate
|
|
||||||
* @return bool True if valid
|
|
||||||
*/
|
|
||||||
public function validateCSRFToken($token) {
|
|
||||||
if (!isset($_SESSION['csrf_token'])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash_equals($_SESSION['csrf_token'], $token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate CSRF token
|
|
||||||
*
|
|
||||||
* @return string CSRF token
|
|
||||||
*/
|
|
||||||
public function generateCSRFToken() {
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = bin2hex(random_bytes(32));
|
|
||||||
$_SESSION['csrf_token'] = $token;
|
|
||||||
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting check
|
|
||||||
*
|
|
||||||
* @param string $identifier Client identifier
|
|
||||||
* @param int $limit Request limit
|
|
||||||
* @param int $window Time window in seconds
|
|
||||||
* @return bool True if within limit
|
|
||||||
*/
|
|
||||||
public function checkRateLimit($identifier, $limit = 100, $window = 3600) {
|
|
||||||
$key = 'rate_limit_' . md5($identifier);
|
|
||||||
$current = time();
|
|
||||||
|
|
||||||
if (!isset($_SESSION[$key])) {
|
|
||||||
$_SESSION[$key] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean old entries
|
|
||||||
$_SESSION[$key] = array_filter($_SESSION[$key], function($timestamp) use ($current, $window) {
|
|
||||||
return $current - $timestamp < $window;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check limit
|
|
||||||
if (count($_SESSION[$key]) >= $limit) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add current request
|
|
||||||
$_SESSION[$key][] = $current;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate file upload
|
|
||||||
*
|
|
||||||
* @param array $file File upload data
|
|
||||||
* @param array $allowedTypes Allowed MIME types
|
|
||||||
* @param int $maxSize Maximum file size in bytes
|
|
||||||
* @return array Validation result
|
|
||||||
*/
|
|
||||||
public function validateFileUpload($file, $allowedTypes = [], $maxSize = 5242880) {
|
|
||||||
$result = ['valid' => false, 'error' => ''];
|
|
||||||
|
|
||||||
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
|
|
||||||
$result['error'] = 'Invalid file upload';
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file size
|
|
||||||
if ($file['size'] > $maxSize) {
|
|
||||||
$result['error'] = 'File too large';
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file type
|
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
||||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
|
||||||
finfo_close($finfo);
|
|
||||||
|
|
||||||
if (!empty($allowedTypes) && !in_array($mimeType, $allowedTypes)) {
|
|
||||||
$result['error'] = 'File type not allowed';
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for dangerous file extensions
|
|
||||||
$dangerousExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'exe', 'bat', 'cmd', 'sh'];
|
|
||||||
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
||||||
|
|
||||||
if (in_array($extension, $dangerousExtensions)) {
|
|
||||||
$result['error'] = 'Dangerous file extension';
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result['valid'] = true;
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get security report
|
|
||||||
*
|
|
||||||
* @return array Security status report
|
|
||||||
*/
|
|
||||||
public function getSecurityReport() {
|
|
||||||
return [
|
|
||||||
'xss_protection' => 'advanced',
|
|
||||||
'csp_headers' => 'enabled',
|
|
||||||
'csrf_protection' => 'enabled',
|
|
||||||
'rate_limiting' => 'enabled',
|
|
||||||
'file_upload_security' => 'enabled',
|
|
||||||
'input_validation' => 'enhanced',
|
|
||||||
'accessibility_preserved' => true,
|
|
||||||
'security_score' => 100,
|
|
||||||
'wcag_compliant' => true
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LogManager - Dynamic logging for CodePress CMS
|
||||||
|
*
|
||||||
|
* Supports three drivers:
|
||||||
|
* - syslog: Send log entries to a remote syslog server (UDP) or local syslog.
|
||||||
|
* - sqlite: Store log entries in a SQLite database (default when no syslog
|
||||||
|
* server is configured and SQLite is available).
|
||||||
|
* - file: Fallback to plain text files when SQLite is unavailable.
|
||||||
|
*
|
||||||
|
* Which event types are recorded is controlled dynamically via the
|
||||||
|
* "logging.events" config section (admin, requests, errors, security,
|
||||||
|
* content, system).
|
||||||
|
*/
|
||||||
|
class LogManager {
|
||||||
|
const EVENT_ADMIN = 'admin';
|
||||||
|
const EVENT_REQUESTS = 'requests';
|
||||||
|
const EVENT_ERRORS = 'errors';
|
||||||
|
const EVENT_SECURITY = 'security';
|
||||||
|
const EVENT_CONTENT = 'content';
|
||||||
|
const EVENT_SYSTEM = 'system';
|
||||||
|
|
||||||
|
private static $config = null;
|
||||||
|
private static $pdo = null;
|
||||||
|
private static $dbPath = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the log manager with the logging config section.
|
||||||
|
*
|
||||||
|
* @param array $loggingConfig The "logging" section from config.json
|
||||||
|
*/
|
||||||
|
public static function init(array $loggingConfig): void
|
||||||
|
{
|
||||||
|
self::$config = $loggingConfig;
|
||||||
|
self::$dbPath = dirname(__DIR__, 3) . '/admin/storage/logs/codepress.sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether logging is enabled at all.
|
||||||
|
*/
|
||||||
|
public static function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return !empty(self::$config['enabled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a given event type should be recorded.
|
||||||
|
*
|
||||||
|
* @param string $event One of the EVENT_* constants
|
||||||
|
*/
|
||||||
|
public static function isEventEnabled(string $event): bool
|
||||||
|
{
|
||||||
|
if (!self::isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$events = self::$config['events'] ?? [];
|
||||||
|
return !empty($events[$event]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local storage driver: 'sqlite' (falling back to 'file' if
|
||||||
|
* SQLite is unavailable). Syslog is an additional output, not a storage
|
||||||
|
* driver, so it never replaces local storage.
|
||||||
|
*/
|
||||||
|
public static function getDriver(): string
|
||||||
|
{
|
||||||
|
$driver = self::$config['driver'] ?? 'sqlite';
|
||||||
|
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
return self::sqliteAvailable() ? 'sqlite' : 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a log entry if the event type is enabled.
|
||||||
|
*
|
||||||
|
* @param string $event Event type (EVENT_* constant)
|
||||||
|
* @param string $level Log level (info, warning, error, debug)
|
||||||
|
* @param string $message Log message
|
||||||
|
* @param array $context Additional structured context
|
||||||
|
*/
|
||||||
|
public static function log(string $event, string $level, string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
if (!self::isEventEnabled($event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'time' => date('Y-m-d H:i:s'),
|
||||||
|
'event' => $event,
|
||||||
|
'level' => $level,
|
||||||
|
'message' => $message,
|
||||||
|
'ip' => $context['ip'] ?? (class_exists('RequestLogger') ? RequestLogger::getClientIp() : ''),
|
||||||
|
'context' => $context,
|
||||||
|
];
|
||||||
|
|
||||||
|
$driver = self::getDriver();
|
||||||
|
|
||||||
|
// Always store locally (sqlite or file fallback) so the dynamic log
|
||||||
|
// in the admin always has entries.
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
self::writeSqlite($entry);
|
||||||
|
} else {
|
||||||
|
self::writeFile($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additionally forward to a remote syslog server if one is configured.
|
||||||
|
$syslogHost = trim(self::$config['syslog_host'] ?? '');
|
||||||
|
if ($syslogHost !== '') {
|
||||||
|
self::writeSyslog($entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a log entry to a remote syslog server over UDP.
|
||||||
|
*/
|
||||||
|
private static function writeSyslog(array $entry): void
|
||||||
|
{
|
||||||
|
$host = trim(self::$config['syslog_host'] ?? '');
|
||||||
|
$port = (int)(self::$config['syslog_port'] ?? 514);
|
||||||
|
$facility = self::syslogFacility(self::$config['syslog_facility'] ?? 'local0');
|
||||||
|
$ident = self::$config['syslog_ident'] ?? 'codepress';
|
||||||
|
|
||||||
|
$severity = self::syslogSeverity($entry['level']);
|
||||||
|
$pri = ($facility * 8) + $severity;
|
||||||
|
|
||||||
|
$msg = '<' . $pri . '>' . date('M d H:i:s') . ' ' . $ident . '[' . getmypid() . ']: '
|
||||||
|
. '[' . $entry['event'] . '] [' . $entry['level'] . '] ' . $entry['message'];
|
||||||
|
|
||||||
|
$sock = @fsockopen('udp://' . $host, $port, $errno, $errstr, 2);
|
||||||
|
if ($sock) {
|
||||||
|
@fwrite($sock, $msg . "\n");
|
||||||
|
@fclose($sock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a log entry in the SQLite database.
|
||||||
|
*/
|
||||||
|
private static function writeSqlite(array $entry): void
|
||||||
|
{
|
||||||
|
$pdo = self::getPdo();
|
||||||
|
if ($pdo === null) {
|
||||||
|
// SQLite failed -> fall back to file
|
||||||
|
self::writeFile($entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO logs (time, event, level, message, ip, context) VALUES (:time, :event, :level, :message, :ip, :context)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
':time' => $entry['time'],
|
||||||
|
':event' => $entry['event'],
|
||||||
|
':level' => $entry['level'],
|
||||||
|
':message' => $entry['message'],
|
||||||
|
':ip' => $entry['ip'],
|
||||||
|
':context' => json_encode($entry['context']),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::writeFile($entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a log entry to a plain text file (fallback driver).
|
||||||
|
*/
|
||||||
|
private static function writeFile(array $entry): void
|
||||||
|
{
|
||||||
|
$dir = dirname(self::$dbPath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
$file = $dir . '/codepress.log';
|
||||||
|
$line = '[' . $entry['time'] . '] [' . $entry['event'] . '] [' . $entry['level'] . '] ['
|
||||||
|
. $entry['ip'] . '] ' . $entry['message'] . "\n";
|
||||||
|
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get (and lazily create) the PDO connection to the SQLite database.
|
||||||
|
*/
|
||||||
|
private static function getPdo(): ?\PDO
|
||||||
|
{
|
||||||
|
if (self::$pdo !== null) {
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
if (!self::sqliteAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = dirname(self::$dbPath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::$pdo = new \PDO('sqlite:' . self::$dbPath);
|
||||||
|
self::$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
|
||||||
|
self::$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
time TEXT NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
ip TEXT,
|
||||||
|
context TEXT
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_event ON logs (event)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_time ON logs (time)');
|
||||||
|
return self::$pdo;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::$pdo = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the SQLite PDO driver is available.
|
||||||
|
*/
|
||||||
|
private static function sqliteAvailable(): bool
|
||||||
|
{
|
||||||
|
return class_exists('PDO') && in_array('sqlite', \PDO::getAvailableDrivers(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a facility name to its syslog numeric value.
|
||||||
|
*/
|
||||||
|
private static function syslogFacility(string $facility): int
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3,
|
||||||
|
'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7,
|
||||||
|
'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11,
|
||||||
|
'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19,
|
||||||
|
'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23,
|
||||||
|
];
|
||||||
|
return $map[$facility] ?? 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a log level to its syslog severity value.
|
||||||
|
*/
|
||||||
|
private static function syslogSeverity(string $level): int
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'debug' => 7,
|
||||||
|
'info' => 6,
|
||||||
|
'notice' => 5,
|
||||||
|
'warning' => 4,
|
||||||
|
'error' => 3,
|
||||||
|
'critical' => 2,
|
||||||
|
'alert' => 1,
|
||||||
|
'emergency' => 0,
|
||||||
|
];
|
||||||
|
return $map[strtolower($level)] ?? 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query recent log entries from the active store.
|
||||||
|
*
|
||||||
|
* @param int $limit Number of entries to return
|
||||||
|
* @param string|null $event Optional event filter
|
||||||
|
* @param string|null $level Optional level filter (info, warning, error, ...)
|
||||||
|
* @param string|null $search Optional text search on the message
|
||||||
|
* @return array List of log entries (newest first)
|
||||||
|
*/
|
||||||
|
public static function getLogs(int $limit = 200, ?string $event = null, ?string $level = null, ?string $search = null): array
|
||||||
|
{
|
||||||
|
$driver = self::getDriver();
|
||||||
|
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
$pdo = self::getPdo();
|
||||||
|
if ($pdo !== null) {
|
||||||
|
try {
|
||||||
|
$sql = 'SELECT time, event, level, message, ip FROM logs';
|
||||||
|
$conds = [];
|
||||||
|
$params = [];
|
||||||
|
if ($event !== null && $event !== '') {
|
||||||
|
$conds[] = 'event = :event';
|
||||||
|
$params[':event'] = $event;
|
||||||
|
}
|
||||||
|
if ($level !== null && $level !== '') {
|
||||||
|
$conds[] = 'level = :level';
|
||||||
|
$params[':level'] = $level;
|
||||||
|
}
|
||||||
|
if ($search !== null && $search !== '') {
|
||||||
|
$conds[] = 'message LIKE :search';
|
||||||
|
$params[':search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
if (!empty($conds)) {
|
||||||
|
$sql .= ' WHERE ' . implode(' AND ', $conds);
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY id DESC LIMIT ' . (int)$limit;
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File fallback
|
||||||
|
$dir = dirname(self::$dbPath);
|
||||||
|
$file = $dir . '/codepress.log';
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$lines = file($file);
|
||||||
|
$lines = array_slice($lines, -$limit);
|
||||||
|
$logs = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
|
||||||
|
if ($event !== null && $event !== '' && $m[2] !== $event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($level !== null && $level !== '' && $m[3] !== $level) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($search !== null && $search !== '' && stripos($m[5], $search) === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$logs[] = [
|
||||||
|
'time' => $m[1],
|
||||||
|
'event' => $m[2],
|
||||||
|
'level' => $m[3],
|
||||||
|
'ip' => $m[4],
|
||||||
|
'message' => $m[5],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_reverse($logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all stored log entries.
|
||||||
|
*/
|
||||||
|
public static function clear(): void
|
||||||
|
{
|
||||||
|
$driver = self::getDriver();
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
$pdo = self::getPdo();
|
||||||
|
if ($pdo !== null) {
|
||||||
|
try {
|
||||||
|
$pdo->exec('DELETE FROM logs');
|
||||||
|
return;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// fall through to file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$dir = dirname(self::$dbPath);
|
||||||
|
$file = $dir . '/codepress.log';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
@file_put_contents($file, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,14 @@ class Logger {
|
|||||||
|
|
||||||
// Write to file with error suppression (graceful degradation)
|
// Write to file with error suppression (graceful degradation)
|
||||||
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
|
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
|
||||||
|
|
||||||
|
// Route through the dynamic log manager (errors/system events)
|
||||||
|
if (class_exists('LogManager')) {
|
||||||
|
$event = ($level === self::ERROR || $level === self::WARNING)
|
||||||
|
? LogManager::EVENT_ERRORS
|
||||||
|
: LogManager::EVENT_SYSTEM;
|
||||||
|
LogManager::log($event, strtolower($level), $message, $context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,6 +44,79 @@ class RequestLogger
|
|||||||
return $ip;
|
return $ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an IP matches any entry in a list of IPs/CIDR ranges.
|
||||||
|
*
|
||||||
|
* Supports exact IPv4/IPv6 addresses and CIDR notation (e.g. 192.168.0.0/16).
|
||||||
|
*
|
||||||
|
* @param string $ip The client IP to test
|
||||||
|
* @param array $list List of IPs and/or CIDR ranges
|
||||||
|
* @return bool True if the IP matches any entry
|
||||||
|
*/
|
||||||
|
public static function ipMatchesList(string $ip, array $list): bool
|
||||||
|
{
|
||||||
|
$ip = trim($ip);
|
||||||
|
if ($ip === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$isV6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||||
|
$packed = $isV6 ? inet_pton($ip) : inet_pton($ip);
|
||||||
|
|
||||||
|
foreach ($list as $entry) {
|
||||||
|
$entry = trim((string)$entry);
|
||||||
|
if ($entry === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if ($entry === $ip) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIDR notation
|
||||||
|
if (strpos($entry, '/') !== false) {
|
||||||
|
[$subnet, $bits] = array_pad(explode('/', $entry, 2), 2, null);
|
||||||
|
$subnet = trim($subnet);
|
||||||
|
$subnetPacked = inet_pton($subnet);
|
||||||
|
if ($subnetPacked === false || $packed === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ensure both are the same address family
|
||||||
|
if (strlen($subnetPacked) !== strlen($packed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$maxBits = strlen($packed) * 8;
|
||||||
|
$bits = (int)$bits;
|
||||||
|
if ($bits < 0 || $bits > $maxBits) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($bits === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$fullBytes = intdiv($bits, 8);
|
||||||
|
$remainingBits = $bits % 8;
|
||||||
|
$match = true;
|
||||||
|
for ($i = 0; $i < $fullBytes; $i++) {
|
||||||
|
if ($subnetPacked[$i] !== $packed[$i]) {
|
||||||
|
$match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($match && $remainingBits > 0) {
|
||||||
|
$mask = 0xFF << (8 - $remainingBits);
|
||||||
|
if ((ord($subnetPacked[$fullBytes]) & $mask) !== (ord($packed[$fullBytes]) & $mask)) {
|
||||||
|
$match = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($match) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static function getClientIp(): string
|
public static function getClientIp(): string
|
||||||
{
|
{
|
||||||
$headerKeys = [
|
$headerKeys = [
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class SearchEngine {
|
|
||||||
private array $index = [];
|
|
||||||
private CacheInterface $cache;
|
|
||||||
|
|
||||||
public function __construct(?CacheInterface $cache = null) {
|
|
||||||
$this->cache = $cache ?? new FileCache();
|
|
||||||
$this->loadIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function indexContent(string $path, string $content, array $metadata = []): void {
|
|
||||||
$words = $this->tokenize($content);
|
|
||||||
$pathHash = md5($path);
|
|
||||||
|
|
||||||
foreach ($words as $word) {
|
|
||||||
if (!isset($this->index[$word])) {
|
|
||||||
$this->index[$word] = [];
|
|
||||||
}
|
|
||||||
if (!in_array($pathHash, $this->index[$word])) {
|
|
||||||
$this->index[$word][] = $pathHash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store metadata for this path
|
|
||||||
$this->cache->set('search_meta_' . $pathHash, [
|
|
||||||
'path' => $path,
|
|
||||||
'title' => $metadata['title'] ?? basename($path),
|
|
||||||
'snippet' => $this->generateSnippet($content),
|
|
||||||
'last_modified' => $metadata['modified'] ?? time()
|
|
||||||
], 86400); // 24 hours
|
|
||||||
|
|
||||||
$this->saveIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search(string $query, int $limit = 20): array {
|
|
||||||
$terms = $this->tokenize($query);
|
|
||||||
$results = [];
|
|
||||||
$pathScores = [];
|
|
||||||
|
|
||||||
foreach ($terms as $term) {
|
|
||||||
if (isset($this->index[$term])) {
|
|
||||||
foreach ($this->index[$term] as $pathHash) {
|
|
||||||
if (!isset($pathScores[$pathHash])) {
|
|
||||||
$pathScores[$pathHash] = 0;
|
|
||||||
}
|
|
||||||
$pathScores[$pathHash]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by relevance (term frequency)
|
|
||||||
arsort($pathScores);
|
|
||||||
|
|
||||||
// Get top results
|
|
||||||
$count = 0;
|
|
||||||
foreach ($pathScores as $pathHash => $score) {
|
|
||||||
if ($count >= $limit) break;
|
|
||||||
|
|
||||||
$metadata = $this->cache->get('search_meta_' . $pathHash);
|
|
||||||
if ($metadata) {
|
|
||||||
$results[] = array_merge($metadata, ['score' => $score]);
|
|
||||||
$count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeFromIndex(string $path): void {
|
|
||||||
$pathHash = md5($path);
|
|
||||||
|
|
||||||
foreach ($this->index as $word => $paths) {
|
|
||||||
$this->index[$word] = array_filter($paths, fn($hash) => $hash !== $pathHash);
|
|
||||||
if (empty($this->index[$word])) {
|
|
||||||
unset($this->index[$word]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->cache->delete('search_meta_' . $pathHash);
|
|
||||||
$this->saveIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearIndex(): void {
|
|
||||||
$this->index = [];
|
|
||||||
$this->cache->clear();
|
|
||||||
$this->saveIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tokenize(string $text): array {
|
|
||||||
// Convert to lowercase, remove punctuation, split into words
|
|
||||||
$text = strtolower($text);
|
|
||||||
$text = preg_replace('/[^\w\s]/u', ' ', $text);
|
|
||||||
$words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
|
|
||||||
|
|
||||||
// Filter out common stop words and short words
|
|
||||||
$stopWords = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can'];
|
|
||||||
$words = array_filter($words, function($word) use ($stopWords) {
|
|
||||||
return strlen($word) > 2 && !in_array($word, $stopWords);
|
|
||||||
});
|
|
||||||
|
|
||||||
return array_unique($words);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function generateSnippet(string $content, int $length = 150): string {
|
|
||||||
// Remove HTML tags and extra whitespace
|
|
||||||
$content = strip_tags($content);
|
|
||||||
$content = preg_replace('/\s+/', ' ', $content);
|
|
||||||
|
|
||||||
if (strlen($content) <= $length) {
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return substr($content, 0, $length) . '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loadIndex(): void {
|
|
||||||
$cached = $this->cache->get('search_index');
|
|
||||||
if ($cached) {
|
|
||||||
$this->index = $cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function saveIndex(): void {
|
|
||||||
$this->cache->set('search_index', $this->index, 86400); // 24 hours
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SimpleTemplate - Lightweight template rendering engine
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Variable replacement with {{variable}} syntax
|
|
||||||
* - Unescaped HTML content with {{{variable}}} syntax
|
|
||||||
* - Conditional blocks with {{#variable}}...{{/variable}}
|
|
||||||
* - Negative conditionals with {{^variable}}...{{/variable}}
|
|
||||||
* - Partial includes with {{>partial}}
|
|
||||||
* - Simple string-based rendering (no external dependencies)
|
|
||||||
*/
|
|
||||||
class SimpleTemplate {
|
|
||||||
private $data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render template with data
|
|
||||||
*
|
|
||||||
* @param string $template Template content with placeholders
|
|
||||||
* @param array $data Data to populate template
|
|
||||||
* @return string Rendered template
|
|
||||||
*/
|
|
||||||
public static function render($template, $data) {
|
|
||||||
$instance = new self();
|
|
||||||
$instance->data = $data;
|
|
||||||
return $instance->renderTemplate($template);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process template and replace placeholders
|
|
||||||
*
|
|
||||||
* @param string $template Template content
|
|
||||||
* @return string Processed template
|
|
||||||
*/
|
|
||||||
private function renderTemplate($template) {
|
|
||||||
// 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)) {
|
|
||||||
// Handle {{#key}}...{{/key}} blocks for arrays (iteration)
|
|
||||||
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
if (preg_match($pattern, $template, $matches)) {
|
|
||||||
$blockTemplate = $matches[1];
|
|
||||||
$replacement = '';
|
|
||||||
|
|
||||||
foreach ($value as $item) {
|
|
||||||
if (is_array($item)) {
|
|
||||||
// Create a temporary template instance for nested data
|
|
||||||
$tempTemplate = new self();
|
|
||||||
$tempTemplate->data = $item;
|
|
||||||
$replacement .= $tempTemplate->renderTemplate($blockTemplate);
|
|
||||||
} else {
|
|
||||||
// Simple array, replace {{.}} with the item value
|
|
||||||
$itemBlock = str_replace('{{.}}', htmlspecialchars($item, ENT_QUOTES, 'UTF-8'), $blockTemplate);
|
|
||||||
$replacement .= $itemBlock;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$template = preg_replace($pattern, $replacement, $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle {{^key}}...{{/key}} blocks (negative condition for empty arrays)
|
|
||||||
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
if (empty($value)) {
|
|
||||||
// Show the content if array is empty
|
|
||||||
if (preg_match($pattern, $template, $matches)) {
|
|
||||||
$template = preg_replace($pattern, $matches[1], $template);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remove the content if array is not empty
|
|
||||||
$template = preg_replace($pattern, '', $template);
|
|
||||||
}
|
|
||||||
} elseif ((is_string($value) && !empty($value)) || (is_bool($value) && $value === true)) {
|
|
||||||
// Handle {{#key}}...{{/key}} blocks for truthy values
|
|
||||||
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
if (preg_match($pattern, $template, $matches)) {
|
|
||||||
$replacement = $matches[1];
|
|
||||||
$template = preg_replace($pattern, $replacement, $template);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle {{^key}}...{{/key}} blocks (negative condition)
|
|
||||||
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
$template = preg_replace($pattern, '', $template);
|
|
||||||
} else {
|
|
||||||
// Handle empty blocks
|
|
||||||
$pattern = '/{{#' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
$template = preg_replace($pattern, '', $template);
|
|
||||||
|
|
||||||
// Handle {{^key}}...{{/key}} blocks (show when empty)
|
|
||||||
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
|
|
||||||
if (preg_match_all($pattern, $template, $matches)) {
|
|
||||||
foreach ($matches[1] as $match) {
|
|
||||||
$template = preg_replace('/{{\^' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s', $match, $template, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle variable replacements
|
|
||||||
foreach ($this->data as $key => $value) {
|
|
||||||
// Handle triple braces for unescaped HTML content
|
|
||||||
if (strpos($template, '{{{' . $key . '}}}') !== false) {
|
|
||||||
$template = str_replace('{{{' . $key . '}}}', is_string($value) ? $value : print_r($value, true), $template);
|
|
||||||
}
|
|
||||||
// Handle double braces for escaped content
|
|
||||||
elseif (strpos($template, '{{' . $key . '}}') !== false) {
|
|
||||||
if (is_string($value)) {
|
|
||||||
$template = str_replace('{{' . $key . '}}', htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), $template);
|
|
||||||
} elseif (is_array($value)) {
|
|
||||||
// For arrays, convert to JSON string for display
|
|
||||||
$template = str_replace('{{' . $key . '}}', htmlspecialchars(json_encode($value), ENT_QUOTES, 'UTF-8'), $template);
|
|
||||||
} else {
|
|
||||||
// Convert other types to string
|
|
||||||
$template = str_replace('{{' . $key . '}}', htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'), $template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace partial includes with data values
|
|
||||||
*
|
|
||||||
* @param array $matches Regex matches from preg_replace_callback
|
|
||||||
* @return string Replacement content
|
|
||||||
*/
|
|
||||||
private function replacePartial($matches) {
|
|
||||||
$partialName = $matches[1];
|
|
||||||
return isset($this->data[$partialName]) ? $this->data[$partialName] : $matches[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use ScssPhp\ScssPhp\Compiler;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeManager - Resolves and renders the active theme
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Resolve the active theme directory from config
|
||||||
|
* - Load theme.json (title, default_layout, template mapping, colors)
|
||||||
|
* - Build a Twig environment rooted at the theme directory
|
||||||
|
* - Compile theme SCSS to CSS (in assets/css_compiled/)
|
||||||
|
* - Map a requested layout to a concrete .twig template, falling back
|
||||||
|
* to the theme's default_layout when the layout is unknown
|
||||||
|
*/
|
||||||
|
class ThemeManager {
|
||||||
|
private $config;
|
||||||
|
private $themeDir;
|
||||||
|
private $themeConfig;
|
||||||
|
private $twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $config Full CMS config (must contain 'theme_dir' and 'theme')
|
||||||
|
*/
|
||||||
|
public function __construct(array $config) {
|
||||||
|
$this->config = $config;
|
||||||
|
$this->themeDir = $config['theme_dir'] ?? (__DIR__ . '/../../../themes/' . ($config['active_theme'] ?? 'default'));
|
||||||
|
$this->themeConfig = $config['theme'] ?? [];
|
||||||
|
|
||||||
|
$loader = new FilesystemLoader($this->themeDir);
|
||||||
|
$this->twig = new Environment($loader, [
|
||||||
|
'cache' => false,
|
||||||
|
'autoescape' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the absolute path of the active theme directory
|
||||||
|
*/
|
||||||
|
public function getThemeDir(): string {
|
||||||
|
return $this->themeDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the raw theme.json config array
|
||||||
|
*/
|
||||||
|
public function getThemeConfig(): array {
|
||||||
|
return $this->themeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the theme title (from theme.json 'title' or 'name')
|
||||||
|
*/
|
||||||
|
public function getTitle(): string {
|
||||||
|
return $this->themeConfig['title'] ?? $this->themeConfig['name'] ?? basename($this->themeDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the theme config section (default_template, background settings, etc.)
|
||||||
|
*/
|
||||||
|
public function getConfig(): array {
|
||||||
|
return $this->themeConfig['config'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the theme template section (layout key => .twig file mapping)
|
||||||
|
*/
|
||||||
|
public function getTemplates(): array {
|
||||||
|
return $this->themeConfig['template'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the .twig template file for a requested layout.
|
||||||
|
*
|
||||||
|
* Templates are defined in theme.json under the "template" section:
|
||||||
|
* { "template": { "full_content": "full_content.twig", ... } }
|
||||||
|
* The default template is defined in the "config" section:
|
||||||
|
* { "config": { "default_template": "full_content", ... } }
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. If the layout is a known key in the "template" section, use its mapped file.
|
||||||
|
* 2. Otherwise fall back to config.default_template.
|
||||||
|
* 3. Final safety net: full_content.twig.
|
||||||
|
*
|
||||||
|
* @param string $layout Requested layout key (e.g. 'left_sidebar')
|
||||||
|
* @return string Template name usable by the Twig loader
|
||||||
|
*/
|
||||||
|
public function getTemplateForLayout(string $layout): string {
|
||||||
|
$layout = trim($layout);
|
||||||
|
$templates = $this->getTemplates();
|
||||||
|
|
||||||
|
if ($layout !== '' && isset($templates[$layout])) {
|
||||||
|
$file = $templates[$layout];
|
||||||
|
if ($this->templateExists($file)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $this->getConfig();
|
||||||
|
$default = $config['default_template'] ?? 'full_content';
|
||||||
|
if (isset($templates[$default])) {
|
||||||
|
$file = $templates[$default];
|
||||||
|
if ($this->templateExists($file)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'full_content.twig';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of available layout keys defined in the "template" section.
|
||||||
|
*
|
||||||
|
* @return array List of layout keys
|
||||||
|
*/
|
||||||
|
public function getLayouts(): array {
|
||||||
|
return array_keys($this->getTemplates());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a template file exists in the theme directory
|
||||||
|
*/
|
||||||
|
private function templateExists(string $file): bool {
|
||||||
|
$path = $this->themeDir . '/' . ltrim($file, '/');
|
||||||
|
return is_file($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a layout template with the given data.
|
||||||
|
*
|
||||||
|
* @param string $layout Requested layout key
|
||||||
|
* @param array $data Template variables
|
||||||
|
* @return string Rendered HTML
|
||||||
|
*/
|
||||||
|
public function render(string $layout, array $data): string {
|
||||||
|
$template = $this->getTemplateForLayout($layout);
|
||||||
|
return $this->twig->render($template, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile the theme's SCSS to CSS (cached by source mtime).
|
||||||
|
* Compiles from assets/scss/theme.scss to assets/css_compiled/theme.css
|
||||||
|
*
|
||||||
|
* @param bool $force Force recompilation
|
||||||
|
* @return string|null Absolute path to the compiled CSS, or null if none
|
||||||
|
*/
|
||||||
|
public function compileCss(bool $force = false): ?string {
|
||||||
|
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
||||||
|
if (!is_file($scssFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outDir = $this->themeDir . '/assets/css_compiled';
|
||||||
|
$outFile = $outDir . '/theme.css';
|
||||||
|
$cacheFile = $outDir . '/.mtime';
|
||||||
|
|
||||||
|
$mtime = filemtime($scssFile);
|
||||||
|
if (!$force && is_file($outFile) && is_file($cacheFile) && (int)file_get_contents($cacheFile) === $mtime) {
|
||||||
|
return $outFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($outDir)) {
|
||||||
|
mkdir($outDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$compiler = new Compiler();
|
||||||
|
$compiler->setImportPaths($this->themeDir . '/assets/scss');
|
||||||
|
$css = $compiler->compileString(file_get_contents($scssFile))->getCss();
|
||||||
|
|
||||||
|
// Remove read-only before writing (file may be locked from previous compile)
|
||||||
|
if (is_file($outFile)) {
|
||||||
|
@chmod($outFile, 0644);
|
||||||
|
}
|
||||||
|
if (is_file($cacheFile)) {
|
||||||
|
@chmod($cacheFile, 0644);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($outFile, $css);
|
||||||
|
file_put_contents($cacheFile, (string)$mtime);
|
||||||
|
|
||||||
|
// Set read-only to prevent manual edits
|
||||||
|
@chmod($outFile, 0444);
|
||||||
|
@chmod($cacheFile, 0444);
|
||||||
|
|
||||||
|
return $outFile;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ThemeManager SCSS compile error: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public URL for the theme CSS.
|
||||||
|
* Uses assets/css_compiled/theme.css (compiled from SCSS by scssphp).
|
||||||
|
*/
|
||||||
|
public function getCssUrl(): ?string {
|
||||||
|
$compiledCss = $this->themeDir . '/assets/css_compiled/theme.css';
|
||||||
|
|
||||||
|
if (is_file($compiledCss)) {
|
||||||
|
return $this->getThemeAssetUrl('css_compiled/theme.css');
|
||||||
|
}
|
||||||
|
|
||||||
|
$compiled = $this->compileCss();
|
||||||
|
if ($compiled !== null) {
|
||||||
|
return $this->getThemeAssetUrl('css_compiled/theme.css');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public URL for the theme JS, or null if unavailable.
|
||||||
|
*/
|
||||||
|
public function getJsUrl(): ?string {
|
||||||
|
$jsFile = $this->themeDir . '/assets/js/theme.js';
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->getThemeAssetUrl('js/theme.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public URL for a theme asset.
|
||||||
|
*/
|
||||||
|
private function getThemeAssetUrl(string $assetPath): string {
|
||||||
|
$themeName = basename($this->themeDir);
|
||||||
|
return '/themes/' . $themeName . '/assets/' . $assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if theme has SCSS source file.
|
||||||
|
*/
|
||||||
|
public function hasScss(): bool {
|
||||||
|
return is_file($this->themeDir . '/assets/scss/theme.scss');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if compiled CSS is newer than SCSS source.
|
||||||
|
*/
|
||||||
|
public function isScssCompiled(): bool {
|
||||||
|
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
||||||
|
$cssFile = $this->themeDir . '/assets/css_compiled/theme.css';
|
||||||
|
|
||||||
|
if (!is_file($scssFile) || !is_file($cssFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filemtime($cssFile) >= filemtime($scssFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if theme has manual CSS file.
|
||||||
|
*/
|
||||||
|
public function hasManualCss(): bool {
|
||||||
|
return is_file($this->themeDir . '/assets/css/theme.css');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of CSS files in theme assets/css/ directory.
|
||||||
|
* Excludes css_compiled directory.
|
||||||
|
*/
|
||||||
|
public function getCssFiles(): array {
|
||||||
|
$cssDir = $this->themeDir . '/assets/css';
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
if (!is_dir($cssDir)) {
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($cssDir) as $file) {
|
||||||
|
if ($file[0] === '.') continue;
|
||||||
|
if (pathinfo($file, PATHINFO_EXTENSION) === 'css') {
|
||||||
|
$files[] = $this->getThemeAssetUrl('css/' . $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of JS files in theme assets/js/ directory.
|
||||||
|
*/
|
||||||
|
public function getJsFiles(): array {
|
||||||
|
$jsDir = $this->themeDir . '/assets/js';
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
if (!is_dir($jsDir)) {
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($jsDir) as $file) {
|
||||||
|
if ($file[0] === '.') continue;
|
||||||
|
if (pathinfo($file, PATHINFO_EXTENSION) === 'js') {
|
||||||
|
$files[] = $this->getThemeAssetUrl('js/' . $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get URL for favicon if it exists.
|
||||||
|
*/
|
||||||
|
public function getFaviconUrl(): ?string {
|
||||||
|
$faviconFile = $this->themeDir . '/assets/img/favicon.svg';
|
||||||
|
if (!is_file($faviconFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->getThemeAssetUrl('img/favicon.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-8
@@ -12,7 +12,6 @@ if (!file_exists($configJsonPath)) {
|
|||||||
$defaultConfig = [
|
$defaultConfig = [
|
||||||
'site_title' => 'CodePress',
|
'site_title' => 'CodePress',
|
||||||
'content_dir' => 'content',
|
'content_dir' => 'content',
|
||||||
'templates_dir' => 'cms/templates',
|
|
||||||
'active_theme' => 'default',
|
'active_theme' => 'default',
|
||||||
'default_page' => 'auto',
|
'default_page' => 'auto',
|
||||||
'language' => [
|
'language' => [
|
||||||
@@ -25,10 +24,11 @@ if (!file_exists($configJsonPath)) {
|
|||||||
],
|
],
|
||||||
'author' => [
|
'author' => [
|
||||||
'name' => 'E. Noorlander',
|
'name' => 'E. Noorlander',
|
||||||
'website' => 'https://noorlander.info'
|
'website' => 'noorlander.info',
|
||||||
|
'git' => ''
|
||||||
],
|
],
|
||||||
'show_version' => true,
|
'show_version' => true,
|
||||||
'enabled_plugins' => ['MQTTTracker', 'HTMLBlock'],
|
'enabled_plugins' => ['HTMLBlock', 'Navigation'],
|
||||||
'features' => [
|
'features' => [
|
||||||
'auto_link_pages' => true,
|
'auto_link_pages' => true,
|
||||||
'search_enabled' => true,
|
'search_enabled' => true,
|
||||||
@@ -53,7 +53,30 @@ if (!file_exists($configJsonPath)) {
|
|||||||
'geoip_mmdb_path' => '',
|
'geoip_mmdb_path' => '',
|
||||||
'geoip_api_url' => '',
|
'geoip_api_url' => '',
|
||||||
'geoip_api_key' => '',
|
'geoip_api_key' => '',
|
||||||
'retention_days' => 400
|
'retention_days' => 400,
|
||||||
|
'excluded_ips' => [
|
||||||
|
'192.168.0.0/16',
|
||||||
|
'10.0.0.0/8',
|
||||||
|
'172.16.0.0/12',
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'logging' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'syslog_host' => '',
|
||||||
|
'syslog_port' => 514,
|
||||||
|
'syslog_facility' => 'local0',
|
||||||
|
'syslog_ident' => 'codepress',
|
||||||
|
'events' => [
|
||||||
|
'admin' => true,
|
||||||
|
'requests' => true,
|
||||||
|
'errors' => true,
|
||||||
|
'security' => true,
|
||||||
|
'content' => true,
|
||||||
|
'system' => true
|
||||||
|
]
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
@file_put_contents($configJsonPath, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
@file_put_contents($configJsonPath, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
@@ -87,24 +110,50 @@ if (file_exists($configJsonPath)) {
|
|||||||
'geoip_api_url' => '',
|
'geoip_api_url' => '',
|
||||||
'geoip_api_key' => '',
|
'geoip_api_key' => '',
|
||||||
'retention_days' => 400,
|
'retention_days' => 400,
|
||||||
|
'excluded_ips' => [
|
||||||
|
'192.168.0.0/16',
|
||||||
|
'10.0.0.0/8',
|
||||||
|
'172.16.0.0/12',
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1'
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'logging' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'syslog_host' => '',
|
||||||
|
'syslog_port' => 514,
|
||||||
|
'syslog_facility' => 'local0',
|
||||||
|
'syslog_ident' => 'codepress',
|
||||||
|
'events' => [
|
||||||
|
'admin' => true,
|
||||||
|
'requests' => true,
|
||||||
|
'errors' => true,
|
||||||
|
'security' => true,
|
||||||
|
'content' => true,
|
||||||
|
'system' => true
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
foreach ($sectionDefaults as $section => $defaults) {
|
foreach ($sectionDefaults as $section => $defaults) {
|
||||||
$config[$section] = array_merge($defaults, is_array($config[$section] ?? null) ? $config[$section] : []);
|
$config[$section] = array_merge($defaults, is_array($config[$section] ?? null) ? $config[$section] : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the private/loopback IP defaults are present when the list is empty
|
||||||
|
if (empty($config['analytics']['excluded_ips'])) {
|
||||||
|
$config['analytics']['excluded_ips'] = $sectionDefaults['analytics']['excluded_ips'];
|
||||||
|
}
|
||||||
|
|
||||||
// Convert relative paths to absolute
|
// Convert relative paths to absolute
|
||||||
$projectRoot = __DIR__ . '/../../';
|
$projectRoot = __DIR__ . '/../../';
|
||||||
if (isset($config['content_dir']) && strpos($config['content_dir'], '/') !== 0) {
|
if (isset($config['content_dir']) && strpos($config['content_dir'], '/') !== 0) {
|
||||||
$config['content_dir'] = $projectRoot . $config['content_dir'];
|
$config['content_dir'] = $projectRoot . $config['content_dir'];
|
||||||
}
|
}
|
||||||
if (isset($config['templates_dir']) && strpos($config['templates_dir'], '/') !== 0) {
|
|
||||||
$config['templates_dir'] = $projectRoot . $config['templates_dir'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load active theme
|
// Load active theme
|
||||||
$activeTheme = $config['active_theme'] ?? 'default';
|
$activeTheme = $config['active_theme'] ?? 'default';
|
||||||
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
|
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
|
||||||
|
$config['theme_dir'] = $themeDir;
|
||||||
$themeFile = $themeDir . '/theme.json';
|
$themeFile = $themeDir . '/theme.json';
|
||||||
if (file_exists($themeFile)) {
|
if (file_exists($themeFile)) {
|
||||||
$themeConfig = json_decode(file_get_contents($themeFile), true);
|
$themeConfig = json_decode(file_get_contents($themeFile), true);
|
||||||
@@ -121,6 +170,5 @@ if (file_exists($configJsonPath)) {
|
|||||||
return [
|
return [
|
||||||
'site_title' => 'CodePress',
|
'site_title' => 'CodePress',
|
||||||
'content_dir' => __DIR__ . '/../../content',
|
'content_dir' => __DIR__ . '/../../content',
|
||||||
'templates_dir' => __DIR__ . '/../templates',
|
|
||||||
'default_page' => 'auto'
|
'default_page' => 'auto'
|
||||||
];
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user