Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e345c96d7 | ||
|
|
34bb8c6f21 | ||
|
|
d9ea2eee47 | ||
|
|
74612aefbb | ||
|
|
6485f693dc |
@@ -14,6 +14,7 @@ Thumbs.db
|
||||
admin/storage/cache/
|
||||
admin/storage/geoip/
|
||||
admin/storage/stats.json
|
||||
admin/storage/logs/login_attempts.json
|
||||
var/
|
||||
|
||||
# Runtime-compiled theme assets (generated by scssphp, read-only)
|
||||
|
||||
+2
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
A lightweight, file-based content management system built with PHP (≥8.0).
|
||||
|
||||
**Version:** 2.6.1 | **License:** AGPL v3 / Commercial
|
||||
**Version:** 2.6.5 | **License:** AGPL v3 / Commercial
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -55,10 +55,9 @@ composer install
|
||||
|
||||
```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`).
|
||||
Edit `config.json` with your site title, language and plugins. On the first visit to `/admin`, `admin/config/admin.json` is created automatically with a random password that is shown on the login screen. Save this password safely and change it immediately after login.
|
||||
|
||||
### Step 3a — Apache 2.4+
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
||||
|
||||
**Versie:** 2.6.1 | **Licentie:** AGPL v3 / Commercial
|
||||
**Versie:** 2.6.5 | **Licentie:** AGPL v3 / Commercial
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -55,10 +55,9 @@ composer install
|
||||
|
||||
```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`).
|
||||
Pas `config.json` aan met je site titel, taal en plugins. Bij de eerste keer dat je `/admin` opent wordt `admin/config/admin.json` automatisch aangemaakt met een willekeurig wachtwoord dat op het inlogscherm wordt getoond. Sla dit wachtwoord veilig op en wijzig het direct na login.
|
||||
|
||||
### Stap 3a — Apache 2.4+
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# TODO
|
||||
|
||||
|
||||
## Voltooid ✅
|
||||
|
||||
- [x] diep link werken niet goed. Pagina titels worden wel herkend maar de gegenereerde link werken niet. — processContent() herschreef geldige /<lang>/<page> URLs naar /-media/ (404). Skip-regex uitgebreid met dynamische taal-prefixen via getAvailableLanguages().
|
||||
- [x] De tree van de admin/content admin/theme en admin/plugin
|
||||
- [x] Mappen en bestanden moeten kunnen worden versleept kunnen worden om te verplaatsen. — Drag-and-drop via tree-interactions.js + uniform AJAX move endpoint (/admin/tree-move) voor alle drie de scopes.
|
||||
- [x] Ik moet in de tree ook een bestand/map kunnen selecteren
|
||||
- [x] om te kunnen editen — klik op bestand opent editor, klik op map opent map-detail paneel
|
||||
- [x] verwijderen — prullenbak-knop per bestand in tree + verwijder-knop per map in map-detail paneel
|
||||
- [x] op deze plaats een nieuwe map/bestand te kunnen maken — per-map actie-knoppen in tree (nieuw bestand, nieuwe map) + knoppen in map-detail paneel
|
||||
- [x] verplaatsen — drag-and-drop + potlood-knop per bestand (verplaats-formulier)
|
||||
- [x] Alle tree's moeten op de zelfde manier kunnen werken — _file-tree.twig is uniform: alle drie de trees hebben treeDirActions, treeDirSelectRoute, drag-drop, per-node actie-knoppen, map-detail paneel. Nieuwe routes: theme-dir-create-in/rename-in/delete-in, plugins-dir-create-in/rename-in/delete-in, tree-move.
|
||||
|
||||
## Nice to have
|
||||
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
|
||||
- [ ] Fase 1: Config-resolutie
|
||||
@@ -24,65 +37,18 @@
|
||||
- [ ] AGENTS.md + config.json.example bijwerken
|
||||
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
||||
|
||||
- [x] Thema editor (thema's net zo bewerkbaar maken als plugins)
|
||||
- [x] Fase 1: Themabestanden bewerken
|
||||
- [x] Nieuwe route theme-edit + handleThemeEdit() (spiegel van handlePluginsEdit)
|
||||
- [x] scanThemeFiles() helper (geneste boom van thema-map, zoals scanPluginFiles; skipt css_compiled/)
|
||||
- [x] theme-edit.twig met bestandsbrowser zijbalk + CodeMirror editor
|
||||
- [x] Bewerkbare extensies: .twig, .json, .scss, .css, .js, .html, .md, .php
|
||||
- [x] theme.json editor (validatie op structuur: name, template mappings, config)
|
||||
- [x] Layout .twig bestanden bewerken (base.twig, full_content.twig, left_sidebar.twig, etc.)
|
||||
- [x] Partials bewerken (header, navigation, footer)
|
||||
- [x] Nieuw bestand aanmaken binnen thema-map (zoals plugin-editor)
|
||||
- [x] Bestand opslaan met CSRF + path-traversal bescherming (realpath + prefix-check)
|
||||
- [x] Fase 2: Media/assets beheer
|
||||
- [x] Uploaden naar assets/ van thema (spiegel van plugins-file-upload)
|
||||
- [x] Bestand verwijderen uit thema (spiegel van plugins-file-delete; theme.json beschermd)
|
||||
- [x] Bestand verplaatsen binnen thema (spiegel van plugins-file-move; theme.json beschermd)
|
||||
- [x] Media invoegen in editor: thema-context scant assets/ map (uitbreiding media-list endpoint met ?theme=)
|
||||
- [x] Fase 3: SCSS compileren
|
||||
- [x] SCSS compileren knop in theme-edit (runtime scssphp compile assets/scss/theme.scss → assets/css_compiled/theme.css)
|
||||
- [x] Compile-status tonen (up-to-date/verouderd badges in theme.twig + theme-edit.twig)
|
||||
- [x] Fase 4: Thema overzicht + activeren
|
||||
- [x] theme.twig: Bewerken-knop per thema (link naar theme-edit)
|
||||
- [x] Activeren per thema (active_theme in config.json via theme-activate route)
|
||||
- [x] Themaverwijdering (alleen niet-actieve, niet-default thema's via theme-delete route)
|
||||
- [x] Theme-new uitbreiden met keuze uit bestaand thema als basis (kopieer structuur, skip css_compiled/)
|
||||
- [x] Fase 5: Uniformiteit + documentatie
|
||||
- [x] Uniforme thema-structuur afdwingen (theme.json, assets/scss, assets/css, assets/js, assets/img, assets/fonts, partials, README.md) via createUniformThemeStructure()
|
||||
- [x] Per thema README.md (gegenereerd bij aanmaken via admin)
|
||||
- [x] guide/nl|en/admin-beheerder/thema-beheer.md bijgewerkt met thema-editor uitleg
|
||||
- [x] theme.json schema documentatie in guide (template mappings, config, header_color, etc.)
|
||||
- [x] Verificatie: php -l, curl testen (aanmaken, bewerken, opslaan, uploaden, verwijderen, verplaatsen, SCSS compileren, activeren, verwijderen), path-traversal tests, default/active theme bescherming
|
||||
## v2.6.5 (2026-08-27) ✅
|
||||
- [x] Bug: dashboard toonde 0 content (AdminPluginAPI::getContentDir() gaf relatief pad "content" terug zonder normalisatie tegen project-root; Apache CWD=public/ → 0 bestanden)
|
||||
- [x] Dynamische pad-resolutie: PluginAPIInterface uitgebreid met getProjectRoot()/getContentDir()/getPluginsDir()/getVersionInfo(); CMSAPI en AdminPluginAPI implementeren deze nu universeel; Navigation plugin en Logs plugin halen paden via de API i.p.v. hardcoded dirname(__DIR__)
|
||||
- [x] public/index.php media-/assets-serving gebruikt $config['content_dir'] i.p.v. hardcoded /content
|
||||
- [x] WordPress-stijl docblocks toegevoegd voor alle classes, methods, properties en functies (~450 docblocks, @since 2.6.5)
|
||||
- [x] Security: hardcoded plaintext-wachtwoord 'admin' verwijderd uit AdminAuth.php; bij eerste installatie wordt een cryptografisch veilig wachtwoord gegenereerd (random_bytes, 16 tekens) en eenmalig op het inlogscherm getoond
|
||||
- [x] Security: git-geschiedenis schoongemaakt — admin/config/admin.json, admin/config/admin.json.example en admin-console/config/admin.json verwijderd uit alle commits (bevatten kraakbare bcrypt-hashes naar 'admin'); filter-branch over alle branches + 10 tags, reflog expired, gc --prune --aggressive
|
||||
- [x] README.md, README.en.md, AGENTS.md bijgewerkt (admin.json.example referenties verwijderd, inlog-instructies herschreven)
|
||||
|
||||
- [x] Content editor consistent met plugins en thema's (uniforme editor-ervaring)
|
||||
- [x] Fase 1: Bestandsbrowser zijbalk voor content
|
||||
- [x] Nieuwe route content-files / handleContentFiles() (spiegel van plugins-edit/theme-edit) naast de bestaande per-pagina content-edit
|
||||
- [x] scanContentFiles() helper (geneste boom van content_dir, zoals scanPluginFiles/scanThemeFiles; via unified scanEditorFiles(scope))
|
||||
- [x] content-files.twig met bestandsbrowser zijbalk + CodeMirror editor (herbruik de plugin/theme editor layout)
|
||||
- [x] Bewerkbare extensies: .md, .php, .html (consistent met bestaande content-edit)
|
||||
- [x] Nieuw bestand aanmaken binnen content_map (zoals plugin/theme-editor) met extensie-keuze + in_dir prefix
|
||||
- [x] Bestand opslaan met CSRF + path-traversal bescherming (realpath + prefix-check op content_dir)
|
||||
- [x] Fase 2: Media/assets beheer (uniform met plugins/thema's)
|
||||
- [x] Uploaden naar content/ (spiegel van plugins-file-upload/theme-file-upload; content-file-upload route)
|
||||
- [x] Bestand verwijderen uit content (spiegel van plugins-file-delete/theme-file-delete; content-file-delete route)
|
||||
- [x] Bestand verplaatsen binnen content (spiegel van plugins-file-move/theme-file-move; content-file-move route)
|
||||
- [x] Media invoegen in editor: content-context (media-list zonder ?plugin/?theme = content/, al aanwezig)
|
||||
- [x] Fase 3: Mappen beheer (uniform met plugins/thema's structuur)
|
||||
- [x] Nieuwe map aanmaken (content-dir-create-in route, zelfde UX als plugin/theme-editor nieuw-bestand, met in_dir)
|
||||
- [x] Map hernoemen (content-dir-rename-in route, binnen editor zijbalk)
|
||||
- [x] Map verwijderen (content-dir-delete-in route, binnen editor zijbalk, alleen lege mappen)
|
||||
- [ ] Map verplaatsen (spiegel van plugins-file-move/theme-file-move voor mappen) — optioneel, niet geïmplementeerd (mappen verplaatsen is zeldzaam; hernoemen + bestand-verplaatsen dekken de use-case)
|
||||
- [x] Fase 4: Uniformiteit + consistentie
|
||||
- [ ] Eén herbruikbare editor-macro/template voor plugin/theme/content (voorkom dupliek Twig-macro's) — uitgesteld: de macro's verschillen per scope (plugin heeft protected-badge, theme heeft theme.json-bescherming, content heeft mappen-acties); samenvoegen zou een complexe macro met conditionals opleveren die minder leesbaar is dan drie focussen macro's
|
||||
- [x] Eén herbruikbare scanFiles() helper die plugin/theme/content afhandelt (scanEditorFiles(dir, scope) + scanEditorFilesNode; scanPluginFiles/scanThemeFiles/scanContentFiles zijn dunne wrappers)
|
||||
- [ ] Eén herbruikbare fileUpload/fileDelete/fileMove handler-familie met scope-parameter (plugin/theme/content) i.p.v. drie bijna-identieke handlers per type — uitgesteld: de handlers hebben scope-specifieke logica (plugin: protected-plugins check; theme: theme.json-bescherming; content: geen specifieke bescherming) die generalisatie met een configuratie-laag vereist; de huidige per-scope handlers zijn duidelijker dan één handler met veel conditionals
|
||||
- [ ] Per content README.md of index.md (zoals plugins/thema's) — optioneel, alleen voor mappen; niet geïmplementeerd
|
||||
- [x] guide/nl|en/admin-beheerder/content-beheer.md bijwerken met content-editor uitleg (zoals plugin-development.md en thema-beheer.md)
|
||||
- [x] Fase 5: Backup/git integratie in editor
|
||||
- [x] ContentBackup (ZIP/git) acties toegankelijk vanuit de content-editor zijbalk (zoals SCSS-compile knop in theme-edit)
|
||||
- [x] git commit/status tonen in de editor wanneer content/ een git repo is (branch, dirty/clean badge, laatste commit)
|
||||
- [x] Verificatie: php -l, curl testen (aanmaken, bewerken, opslaan, uploaden, verwijderen, verplaatsen, map aanmaken/hernoemen/verwijderen), path-traversal tests (delete/new_file/upload alle geblokkeerd), pentest, WCAG
|
||||
## v2.6.4 (2026-08-26) ✅
|
||||
- [x] Bug: diep link werken niet goed — processContent() herschreef geldige /<lang>/<page> URLs naar /-media/ (404); skip-regex dynamisch uitgebreid met getAvailableLanguages()
|
||||
- [x] Bug: uniforme tree met drag-and-drop voor admin/content, admin/theme, admin/plugin — tree-interactions.js (drag-drop + per-node actie-knoppen), uniform AJAX move endpoint (/admin/tree-move), map-detail paneel voor alle drie de scopes, theme/plugin dir create/rename/delete routes, handleidingen bijgewerkt
|
||||
|
||||
## Voltooid ✅
|
||||
|
||||
@@ -100,6 +66,18 @@
|
||||
- [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)
|
||||
|
||||
## v2.6.3 (2026-08-20) ✅
|
||||
- [x] Content bestanden met dezelfde naam maar ander type (md/php/html) worden door de frontend correct geserveerd: URL met extensie opent dat specifieke bestand, URL zonder extensie valt terug op md > php > html prioriteit (resolveContentByType helper)
|
||||
- [x] Admin content editor accepteert bestanden met dezelfde naam (ander type); "bestaat al" check is per extensie, melding verduidelijkt naar "Bestand met dit type bestaat al."
|
||||
- [x] Admin content editor preview-knop linkt per extensie (test.php → /nl/test.php) zodat elk type individueel te bekijken blijft
|
||||
- [x] Frontend navigatie, directory listing en search tonen elk bestandstype apart (extensie behouden in URL), geen samenvoeging meer
|
||||
- [x] getAllPages() array structuur gewijzigd naar list van ['path' => ..., 'title' => ..., 'type' => ...] met type 'md'/'php'/'html'/'folder'; mappen krijgen eigen entry, bestanden met dezelfde naam botsen niet meer
|
||||
- [x] Verberg-prefix logica gewijzigd: `_` is geen verberg-prefix meer, alleen `.` (en `-`) verbergen aan de frontend; admin toont wél alle `.` bestanden/mappen (.bak, .map) — scanEditorFilesNode content-scope toont dotfiles behalve .git/.gitkeep
|
||||
- [x] ContentAPI getPage()/pageExists() respecteren expliciete extensie in $path
|
||||
- [x] autoLinkPageTitles() aangepast aan nieuwe array structuur, slaat mappen over
|
||||
- [x] Handleiding content-api.md (NL+EN) herschreven: echo/return mechanisme, beschikbare variabelen, nieuwe getAllPages() structuur met voorbeelden
|
||||
- [x] Versie verhoogd naar 2.6.3
|
||||
|
||||
## v2.6.2 (2026-08-19) ✅
|
||||
- [x] Pentest controles uitgevoerd (30/30)
|
||||
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
||||
|
||||
+421
-20
@@ -1,18 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AdminAuth - File-based authentication for CodePress Admin
|
||||
* File-based authenticatie en autorisatie voor het CodePress admin paneel.
|
||||
*
|
||||
* Beheert gebruikerssessies, rollen met permissies, CSRF-tokens, brute-force
|
||||
* lockout en gebruikersbeheer. Alle data wordt opgeslagen in JSON-bestanden
|
||||
* (geen database).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class AdminAuth
|
||||
{
|
||||
/**
|
||||
* Applicatie-configuratie uit config.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array Applicatie-configuratie.
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* Admin-configuratie geladen uit admin.json (gebruikers en security-instellingen).
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array Admin-configuratie met keys 'users' en 'security'.
|
||||
*/
|
||||
private array $adminConfig;
|
||||
|
||||
/**
|
||||
* Pad naar het JSON-bestand met mislukte inlogpogingen voor brute-force lockout.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string Absoluut pad naar login_attempts.json.
|
||||
*/
|
||||
private string $lockFile;
|
||||
|
||||
/**
|
||||
* Role definitions with permissions.
|
||||
* Each role maps to a list of allowed route prefixes.
|
||||
* 'admin' has wildcard '*' access.
|
||||
* Tijdelijk gegenereerd wachtwoord bij eerste installatie.
|
||||
*
|
||||
* Wordt gevuld wanneer loadAdminConfig() een nieuwe admin.json aanmaakt
|
||||
* met een cryptografisch veilig willekeurig wachtwoord. De login-pagina
|
||||
* toont dit wachtwoord eenmalig via getGeneratedPassword().
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string Leeg tenzij net aangemaakt bij eerste installatie.
|
||||
*/
|
||||
private string $generatedPassword = '';
|
||||
|
||||
/**
|
||||
* Permissies per rol.
|
||||
*
|
||||
* Elke rol mapt naar een lijst van toegestane route-prefixen. De rol
|
||||
* 'admin' heeft wildcard '*' toegang tot alle routes.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string, string[]> Map van rol-sleutel naar lijst met toegestane routes.
|
||||
*/
|
||||
public const ROLE_PERMISSIONS = [
|
||||
'admin' => ['*'],
|
||||
@@ -22,7 +64,10 @@ class AdminAuth
|
||||
];
|
||||
|
||||
/**
|
||||
* Human-readable role labels.
|
||||
* Leesbare rol-labels voor weergave in de admin-interface.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string, string> Map van rol-sleutel naar label.
|
||||
*/
|
||||
public const ROLE_LABELS = [
|
||||
'admin' => 'Admin',
|
||||
@@ -31,6 +76,16 @@ class AdminAuth
|
||||
'site-admin' => 'Site Admin',
|
||||
];
|
||||
|
||||
/**
|
||||
* Constructor: initialiseer authenticatie met de applicatie-configuratie.
|
||||
*
|
||||
* Laadt de admin-configuratie, bepaalt het pad naar het lockout-bestand en
|
||||
* start de sessie met de juiste security-instellingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $appConfig Applicatie-configuratie; vereist keys 'log_file' en 'admin_config'.
|
||||
*/
|
||||
public function __construct(array $appConfig)
|
||||
{
|
||||
$this->config = $appConfig;
|
||||
@@ -39,6 +94,17 @@ class AdminAuth
|
||||
$this->startSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Laadt de admin-configuratie uit admin.json.
|
||||
*
|
||||
* Als het bestand nog niet bestaat wordt het aangemaakt vanuit
|
||||
* admin.json.example, of indien die ook ontbreekt met een standaard admin-
|
||||
* gebruiker. Geeft een lege structuur terug als de JSON ongeldig is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array Admin-configuratie met keys 'users' en 'security'.
|
||||
*/
|
||||
private function loadAdminConfig(): array
|
||||
{
|
||||
$path = $this->config['admin_config'];
|
||||
@@ -48,11 +114,19 @@ class AdminAuth
|
||||
if (file_exists($examplePath)) {
|
||||
@copy($examplePath, $path);
|
||||
} else {
|
||||
// Genereer een cryptografisch veilig wachtwoord bij eerste
|
||||
// installatie. Het wachtwoord wordt nooit in de broncode
|
||||
// opgeslagen; de hash wordt weggeschreven naar admin.json
|
||||
// en het plaintext wachtwoord éénmalig getoond op het
|
||||
// inlogscherm via getGeneratedPassword().
|
||||
$plainPassword = $this->generateRandomPassword();
|
||||
$this->generatedPassword = $plainPassword;
|
||||
|
||||
$defaultAdminConfig = [
|
||||
'users' => [
|
||||
[
|
||||
'username' => 'admin',
|
||||
'password_hash' => password_hash('admin', PASSWORD_BCRYPT),
|
||||
'password_hash' => password_hash($plainPassword, PASSWORD_BCRYPT),
|
||||
'role' => 'admin',
|
||||
'created' => date('Y-m-d'),
|
||||
]
|
||||
@@ -74,6 +148,55 @@ class AdminAuth
|
||||
return is_array($data) ? $data : ['users' => [], 'security' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereer een cryptografisch veilig willekeurig wachtwoord.
|
||||
*
|
||||
* Gebruik random_bytes() (CSPRNG) met een mix van alfanumerieke tekens
|
||||
* en een vaste lengte, zodat het wachtwoord voldoende entropie heeft
|
||||
* en veilig te typen is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Willekeurig wachtwoord van 16 alfanumerieke tekens.
|
||||
*/
|
||||
private function generateRandomPassword(int $length = 16): string
|
||||
{
|
||||
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$max = strlen($alphabet) - 1;
|
||||
$password = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$password .= $alphabet[random_int(0, $max)];
|
||||
}
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geef het bij eerste installatie gegenereerde wachtwoord terug.
|
||||
*
|
||||
* De login-pagina gebruikt dit om het wachtwoord eenmalig te tonen
|
||||
* zodra admin.json is aangemaakt. Na de eerste succesvolle login
|
||||
* wordt het veld geleegd. Geeft een lege string terug wanneer er
|
||||
* geen nieuw wachtwoord is gegenereerd in deze request.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Plaintext wachtwoord of lege string.
|
||||
*/
|
||||
public function getGeneratedPassword(): string
|
||||
{
|
||||
return $this->generatedPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat de huidige admin-configuratie op naar admin.json.
|
||||
*
|
||||
* Schrijft de interne admin-configuratie weg als pretty-printed JSON met
|
||||
* behoud van unicode-tekens.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function saveAdminConfig(): void
|
||||
{
|
||||
file_put_contents(
|
||||
@@ -82,6 +205,17 @@ class AdminAuth
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start de sessie met veilige cookie-parameters en handhaaft de session timeout.
|
||||
*
|
||||
* Stelt cookie-flags in (HttpOnly, SameSite=Strict, Secure bij HTTPS), start
|
||||
* de sessie en beëindigt deze bij inactiviteit langer dan de geconfigureerde
|
||||
* timeout. Vernieuwt de activiteit-timestamp bij geauthenticeerde gebruikers.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
@@ -113,6 +247,20 @@ class AdminAuth
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifieert inloggegevens en start een admin-sessie bij succes.
|
||||
*
|
||||
* Controleert eerst of het account niet is vergrendeld door brute-force
|
||||
* protection. Bij een fout wachtwoord wordt een mislukte poging geregistreerd
|
||||
* en gelogd. Bij succes worden mislukte pogingen gewist en de sessie
|
||||
* gevuld met gebruiker, rol en CSRF-token.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @param string $password Wachtwoord (plaintext).
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function login(string $username, string $password): array
|
||||
{
|
||||
// Check brute-force lockout
|
||||
@@ -145,6 +293,16 @@ class AdminAuth
|
||||
return ['success' => true, 'message' => 'Ingelogd.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logt de huidige gebruiker uit en vernietigt de sessie.
|
||||
*
|
||||
* Maakt de sessie-array leeg, verwijdert het sessie-cookie en vernietigt de
|
||||
* sessie. De uitlogactie wordt gelogd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
$user = $_SESSION['admin_user'] ?? 'unknown';
|
||||
@@ -160,11 +318,28 @@ class AdminAuth
|
||||
$this->log('info', "Uitgelogd: {$user}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of de huidige bezoeker is ingelogd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien een admin-gebruiker in de sessie staat.
|
||||
*/
|
||||
public function isAuthenticated(): bool
|
||||
{
|
||||
return isset($_SESSION['admin_user']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de gegevens van de ingelogde gebruiker, verrijkt met profielvelden.
|
||||
*
|
||||
* Levert username en rol uit de sessie en vult deze aan met email,
|
||||
* author_name en author_email uit admin.json indien aanwezig.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array|null Gebruikersdata als array, of null indien niet ingelogd.
|
||||
*/
|
||||
public function getCurrentUser(): ?array
|
||||
{
|
||||
if (!$this->isAuthenticated()) {
|
||||
@@ -186,8 +361,14 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role of the current user.
|
||||
* Returns the override role if set (for testing), otherwise the real role.
|
||||
* Geeft de huidige rol van de ingelogde gebruiker.
|
||||
*
|
||||
* Retourneert de override-rol indien ingesteld (voor test-doeleinden), anders
|
||||
* de werkelijke rol.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Rol-sleutel.
|
||||
*/
|
||||
public function getCurrentRole(): string
|
||||
{
|
||||
@@ -198,7 +379,11 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the real role of the current user (ignoring any override).
|
||||
* Geeft de werkelijke rol van de ingelogde gebruiker, eventuele override negerend.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Rol-sleutel.
|
||||
*/
|
||||
public function getRealRole(): string
|
||||
{
|
||||
@@ -206,7 +391,11 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has an active role override.
|
||||
* Controleert of de ingelogde gebruiker een actieve rol-override heeft.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien een override-rol is ingesteld.
|
||||
*/
|
||||
public function hasRoleOverride(): bool
|
||||
{
|
||||
@@ -214,8 +403,16 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily switch to another role for testing purposes.
|
||||
* Only admin users can do this; the override cannot grant more than admin.
|
||||
* Wisselt tijdelijk naar een andere rol voor test-doeleinden.
|
||||
*
|
||||
* Alleen werkelijke admins kunnen wisselen; admins kunnen niet wisselen naar
|
||||
* 'admin' (zinloos) en enkel naar lagere rollen. Slaat de override op in de
|
||||
* sessie. De actie wordt gelogd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $role Doel-rol-sleutel.
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function switchRole(string $role): array
|
||||
{
|
||||
@@ -240,7 +437,13 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the role override back to the real admin role.
|
||||
* Reset de rol-override terug naar de werkelijke admin-rol.
|
||||
*
|
||||
* Verwijdert de override uit de sessie indien aanwezig en logt de actie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function resetRole(): array
|
||||
{
|
||||
@@ -253,7 +456,15 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has permission to access a route.
|
||||
* Controleert of de huidige gebruiker een bepaalde route mag openen.
|
||||
*
|
||||
* Gebruikt de permissies van de huidige rol; de wildcard '*' geeft toegang
|
||||
* tot alles. Onbekende rollen krijgen enkel 'dashboard' en 'logout'.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $route Route-sleutel die gecontroleerd moet worden.
|
||||
* @return bool True indien de gebruiker de route mag benaderen.
|
||||
*/
|
||||
public function hasPermission(string $route): bool
|
||||
{
|
||||
@@ -268,7 +479,11 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles.
|
||||
* Geeft alle beschikbare rollen met hun leesbare labels.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string, string> Map van rol-sleutel naar label.
|
||||
*/
|
||||
public static function getRoles(): array
|
||||
{
|
||||
@@ -276,13 +491,28 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role label.
|
||||
* Geeft het leesbare label voor een rol.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $role Rol-sleutel.
|
||||
* @return string Leesbaar label, of de rol-sleutel zelf indien onbekend.
|
||||
*/
|
||||
public static function getRoleLabel(string $role): string
|
||||
{
|
||||
return self::ROLE_LABELS[$role] ?? $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft het huidige CSRF-token, of genereert een nieuw token indien afwezig.
|
||||
*
|
||||
* Slaat een cryptografisch willekeurig token op in de sessie en retourneert
|
||||
* dit voor gebruik in admin-formulieren.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string 64-tekens hexadecimaal CSRF-token.
|
||||
*/
|
||||
public function getCsrfToken(): string
|
||||
{
|
||||
if (!isset($_SESSION['admin_csrf_token'])) {
|
||||
@@ -291,11 +521,31 @@ class AdminAuth
|
||||
return $_SESSION['admin_csrf_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifieert een meegegeven CSRF-token tegen het token in de sessie.
|
||||
*
|
||||
* Gebruikt hash_equals om timing-attacks te voorkomen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $token Token uit het formulier of verzoek.
|
||||
* @return bool True indien het token overeenkomt met het sessie-token.
|
||||
*/
|
||||
public function verifyCsrf(string $token): bool
|
||||
{
|
||||
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regeneert het CSRF-token in de sessie.
|
||||
*
|
||||
* Maakt een nieuw cryptografisch willekeurig token aan, nuttig na
|
||||
* privilege-wijzigingen of na het verwerken van een formulier.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function regenerateCsrfToken(): void
|
||||
{
|
||||
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
||||
@@ -303,6 +553,16 @@ class AdminAuth
|
||||
|
||||
// --- User Management ---
|
||||
|
||||
/**
|
||||
* Geeft alle gebruikers met hun profiel- en rolinformatie.
|
||||
*
|
||||
* Levert een array geïndexeerd op gebruikersnaam, aangevuld met het
|
||||
* leesbare rol-label en eventuele profielvelden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string, array> Map van gebruikersnaam naar gebruikersdata.
|
||||
*/
|
||||
public function getUsers(): array
|
||||
{
|
||||
$users = [];
|
||||
@@ -321,6 +581,23 @@ class AdminAuth
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voegt een nieuwe gebruiker toe aan de admin-configuratie.
|
||||
*
|
||||
* Controleert op dubbele gebruikersnamen, minimum wachtwoordlengte (8) en
|
||||
* geldigheid van de rol. Het wachtwoord wordt opgeslagen als bcrypt-hash.
|
||||
* Wijzigingen worden direct weggeschreven en gelogd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @param string $password Wachtwoord (plaintext, minimaal 8 tekens).
|
||||
* @param string $role Rol-sleutel. Default 'admin'.
|
||||
* @param string $email E-mailadres van de gebruiker. Default ''.
|
||||
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
||||
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||
{
|
||||
if ($this->findUser($username)) {
|
||||
@@ -348,7 +625,18 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the profile (email, author_name, author_email) of a user.
|
||||
* Werkt de profielvelden (email, author_name, author_email) van een gebruiker bij.
|
||||
*
|
||||
* Zoekt de gebruiker op naam en overschrijft de profielvelden, slaat de
|
||||
* configuratie op en logt de wijziging.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @param string $email E-mailadres. Default ''.
|
||||
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
||||
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||
{
|
||||
@@ -366,8 +654,17 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the role of an existing user.
|
||||
* A user can never change their own role (security guard).
|
||||
* Wijzigt de rol van een bestaande gebruiker.
|
||||
*
|
||||
* Een gebruiker kan nooit zijn eigen rol wijzigen (security guard). De rol
|
||||
* moet een bekende sleutel uit ROLE_PERMISSIONS zijn. Wijzigingen worden
|
||||
* opgeslagen en gelogd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @param string $role Nieuwe rol-sleutel.
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function changeRole(string $username, string $role): array
|
||||
{
|
||||
@@ -388,6 +685,17 @@ class AdminAuth
|
||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert een gebruiker uit de admin-configuratie.
|
||||
*
|
||||
* Een gebruiker kan zichzelf niet verwijderen. De gebruikerslijst wordt
|
||||
* gefilterd, opgeslagen en de actie gelogd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function deleteUser(string $username): array
|
||||
{
|
||||
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
||||
@@ -403,6 +711,18 @@ class AdminAuth
|
||||
return ['success' => true, 'message' => 'Gebruiker verwijderd.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet een nieuw wachtwoord voor een gebruiker.
|
||||
*
|
||||
* Controleert de minimum wachtwoordlengte (8), hasht het wachtwoord met de
|
||||
* standaard PHP-hash en slaat de wijziging op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function changePassword(string $username, string $newPassword): array
|
||||
{
|
||||
if (strlen($newPassword) < 8) {
|
||||
@@ -419,6 +739,19 @@ class AdminAuth
|
||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wijzigt het eigen wachtwoord na verificatie van het huidige wachtwoord.
|
||||
*
|
||||
* Verifieert eerst het huidige wachtwoord tegen de opgeslagen hash, controleert
|
||||
* de lengte van het nieuwe wachtwoord (minimaal 8) en slaat de nieuwe hash op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @param string $currentPassword Huidig wachtwoord (plaintext).
|
||||
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
||||
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||
*/
|
||||
public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array
|
||||
{
|
||||
$user = $this->findUser($username);
|
||||
@@ -444,6 +777,14 @@ class AdminAuth
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
/**
|
||||
* Zoekt een gebruiker op gebruikersnaam in de admin-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @return array|null Gebruikersdata als array, of null indien niet gevonden.
|
||||
*/
|
||||
private function findUser(string $username): ?array
|
||||
{
|
||||
foreach ($this->adminConfig['users'] ?? [] as $user) {
|
||||
@@ -454,6 +795,18 @@ class AdminAuth
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of een gebruiker momenteel is vergrendeld door brute-force protection.
|
||||
*
|
||||
* Vergelijkt het aantal mislukte pogingen en de verstreken tijd sinds de
|
||||
* laatste poging met de geconfigureerde limieten. Verloopt de lockout dan
|
||||
* worden de pogingen gewist.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @return array Resultaat met keys 'locked' (bool) en optioneel 'remaining' (int, seconden).
|
||||
*/
|
||||
private function checkLockout(string $username): array
|
||||
{
|
||||
$attempts = $this->getFailedAttempts();
|
||||
@@ -477,6 +830,17 @@ class AdminAuth
|
||||
return ['locked' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registreert een mislukte inlogpoging voor een gebruiker.
|
||||
*
|
||||
* Verhoogt de teller en actualiseert de timestamp van de laatste poging,
|
||||
* waarna de gegevens worden weggeschreven naar het lockout-bestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @return void
|
||||
*/
|
||||
private function recordFailedAttempt(string $username): void
|
||||
{
|
||||
$attempts = $this->getFailedAttempts();
|
||||
@@ -488,6 +852,16 @@ class AdminAuth
|
||||
file_put_contents($this->lockFile, json_encode($attempts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wist alle mislukte inlogpogingen voor een gebruiker.
|
||||
*
|
||||
* Verwijdert de gebruiker uit het lockout-bestand en slaat dit op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @return void
|
||||
*/
|
||||
private function clearFailedAttempts(string $username): void
|
||||
{
|
||||
$attempts = $this->getFailedAttempts();
|
||||
@@ -496,13 +870,28 @@ class AdminAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all failed login attempts for a user (public, used by CLI reset).
|
||||
* Wist de lockout voor een gebruiker (publiek, gebruikt door CLI reset).
|
||||
*
|
||||
* Wrapper rond clearFailedAttempts die ook van buiten de class kan worden
|
||||
* aangeroepen, bijvoorbeeld door een CLI-tool voor het resetten van vergrendelingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $username Gebruikersnaam.
|
||||
* @return void
|
||||
*/
|
||||
public function clearLockout(string $username): void
|
||||
{
|
||||
$this->clearFailedAttempts($username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leest alle mislukte inlogpogingen uit het lockout-bestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string, array{count: int, last_attempt: int}> Map van gebruikersnaam naar poging-data.
|
||||
*/
|
||||
private function getFailedAttempts(): array
|
||||
{
|
||||
if (!file_exists($this->lockFile)) {
|
||||
@@ -512,6 +901,18 @@ class AdminAuth
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schrijft een bericht naar het logbestand met timestamp, level en IP-adres.
|
||||
*
|
||||
* Maakt de logdirectory aan indien nodig. Het IP-adres wordt via RequestLogger
|
||||
* bepaald indien beschikbaar, anders via REMOTE_ADDR met een fallback.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $level Log-niveau (bijv. 'info', 'warning').
|
||||
* @param string $message Bericht dat gelogd moet worden.
|
||||
* @return void
|
||||
*/
|
||||
private function log(string $level, string $message): void
|
||||
{
|
||||
$logFile = $this->config['log_file'];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"admi":{"count":1,"last_attempt":1771257322}}
|
||||
@@ -10,8 +10,7 @@
|
||||
<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 { background-color: {{ sidebar_color|default('#0a369d') }}; height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; 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; }
|
||||
@@ -24,7 +23,7 @@
|
||||
.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-sidebar { width: 100%; height: auto; position: relative; }
|
||||
.admin-main { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -35,7 +34,7 @@
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="admin-sidebar d-flex flex-column">
|
||||
<nav class="admin-sidebar" id="adminSidebar">
|
||||
<div class="admin-brand">
|
||||
<i class="bi bi-gear-fill"></i> {{ ta.admin_title|default('CodePress Admin') }}
|
||||
</div>
|
||||
@@ -134,7 +133,7 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="nav flex-column mt-auto mb-5">
|
||||
<ul class="nav flex-column mt-3 mb-4">
|
||||
{% if user_real_role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#roleSwitchModal">
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if generated_password %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle-fill"></i> Eerste installatie</h6>
|
||||
<p class="mb-2">Er is nog geen <code>admin.json</code> aanwezig. Een nieuw wachtwoord is gegenereerd voor de gebruiker <strong>admin</strong>:</p>
|
||||
<p class="mb-2"><code class="fs-5">{{ generated_password }}</code></p>
|
||||
<p class="mb-0 small">Log in met dit wachtwoord en wijzig het direct na login. Sla het veilig op; het wordt niet opnieuw getoond.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="/admin/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{# Shared editor layout + file-tree styles for the content, plugin and theme editors.
|
||||
Included via {% include 'pages/_editor-styles.twig' %} inside the extra_css block. #}
|
||||
<style>
|
||||
.editor-layout { display: flex; gap: 1rem; align-items: stretch; }
|
||||
.plugin-file-tree { width: 300px; flex: 0 0 300px; }
|
||||
.editor-main { flex: 1 1 auto; min-width: 0; }
|
||||
.editor-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||
.editor-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||
.editor-tree li { padding: 0; position: relative; }
|
||||
.editor-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
|
||||
.editor-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||
.editor-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||
.editor-tree .tree-toggle.is-selected { background-color: rgba(13,110,253,.12); font-weight: 600; }
|
||||
.editor-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||
.editor-tree .tree-chevron { flex: 0 0 auto; }
|
||||
.editor-tree .tree-chevron-btn { background: transparent; border: 0; color: inherit; padding: .2rem .4rem; line-height: 1.4; flex: 0 0 auto; cursor: pointer; }
|
||||
.editor-tree .tree-chevron-btn:hover { color: var(--bs-primary, #0d6efd); }
|
||||
.editor-tree .tree-name { flex: 1 1 auto; min-width: 0; }
|
||||
.editor-tree .tree-name.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||
.editor-tree .tree-root-link { display: flex; align-items: center; gap: .3rem; padding: .25rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; font-weight: 600; }
|
||||
.editor-tree .tree-root-link:hover { background-color: rgba(13,110,253,.08); }
|
||||
.editor-tree .tree-root-link.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); }
|
||||
.editor-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||
.editor-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||
.editor-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.editor-tree .tree-move-btn,
|
||||
.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; background: transparent; border: 0; }
|
||||
.editor-tree .tree-move-btn:hover,
|
||||
.editor-tree .tree-delete-btn:hover { color: #0d6efd; }
|
||||
.editor-tree .tree-delete-btn:hover { color: #dc3545; }
|
||||
.editor-tree .tree-file-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; opacity: 0; transition: opacity .15s; }
|
||||
.editor-tree li:hover > .tree-file-actions { opacity: 1; }
|
||||
.editor-tree li > .tree-link { padding-right: 4rem; }
|
||||
/* Drag-and-drop states */
|
||||
.editor-tree li.tree-dragging { opacity: .5; }
|
||||
.editor-tree .tree-drag-over { background-color: rgba(13,110,253,.18) !important; outline: 2px dashed var(--bs-primary, #0d6efd); outline-offset: -2px; border-radius: .25rem; }
|
||||
/* Toast */
|
||||
.tree-toast { border-radius: .375rem; }
|
||||
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||
/* Directory action buttons (new file/dir, rename) — content editor only */
|
||||
.tree-dir-row { position: relative; display: flex; align-items: center; }
|
||||
.tree-dir-row > .tree-name { padding-right: 3rem; }
|
||||
.tree-dir-row > .tree-toggle { padding-right: 5rem; }
|
||||
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
|
||||
.tree-dir-actions .btn-link { color: #6c757d; }
|
||||
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
|
||||
@media (max-width: 768px) {
|
||||
.editor-layout { flex-direction: column; }
|
||||
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||
.plugin-file-tree .card-body { max-height: 260px; }
|
||||
}
|
||||
/* Plugin checkbox group (content editor) */
|
||||
.plugin-checkbox-group { display: flex; flex-direction: column; gap: .15rem; }
|
||||
.plugin-checkbox-group .form-check { margin-bottom: 0; }
|
||||
.plugin-checkbox-group .form-check-label { font-size: .875rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
{# Shared file-tree partial used by the content, plugin and theme editors.
|
||||
Renders a nested, collapsible file tree from a list of nodes produced by
|
||||
scanEditorFiles()/scanContentFiles()/scanPluginFiles()/scanThemeFiles().
|
||||
|
||||
Required variables (passed via include with ... context):
|
||||
- files: array of nodes [{name, path, is_dir, extension, size, modified, children}]
|
||||
- relFile: currently selected relative file path (for active highlight + auto-expand)
|
||||
- editableExts: array of editable extensions (shown as .EXT badge)
|
||||
- treeIdPrefix: prefix for collapse element ids (e.g. 'content-tree', 'plugin-tree', 'theme-tree')
|
||||
- treeRouteBase: admin route base used to build file links, e.g. '/admin/content'
|
||||
- treeRouteParams: extra query string for file links, e.g. 'plugin=MyPlugin&' or 'theme=default&' or '' (content)
|
||||
- treeMoveRoute: admin route for file move, e.g. '/admin/plugins-file-move'
|
||||
- treeMoveParams: extra query string for move link, e.g. 'plugin=X&' or ''
|
||||
- treeDirActions: boolean; show per-folder new-file/new-dir/rename buttons (default true)
|
||||
- treeFileMove: boolean; show per-file move/rename pencil button (default true)
|
||||
- treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json'])
|
||||
- treeDirSelectRoute: if set, folder names are links to ?dir=<path> (map-detail pane)
|
||||
- treeDirRenameRoute: if set, show a rename pencil per folder
|
||||
- treeDeleteBase: POST URL for file deletion (scope-specific)
|
||||
- treeScope: 'content' | 'theme' | 'plugin' (for tree-interactions.js)
|
||||
- treeMoveUrl: AJAX endpoint for drag-drop move (e.g. '/admin/tree-move')
|
||||
- treeScopeParam: extra query param string (e.g. 'plugin=MyPlugin&' or 'theme=default&' or '')
|
||||
|
||||
Icon maps are chosen automatically from the node extension. #}
|
||||
{% macro tree(nodes, opts) %}
|
||||
{% import _self as macros %}
|
||||
<ul class="editor-tree">
|
||||
{% for n in nodes %}
|
||||
{# Een bestand is alleen actief wanneer er geen map geselecteerd is
|
||||
(een map-selectie opent het map-detail paneel, geen bestand). #}
|
||||
{% set isActive = n.path == opts.relFile and opts.selectedDir|default('') == '' %}
|
||||
{# Markeer de oudermap "bevat actieve selectie" voor auto-uitklappen:
|
||||
bij een geselecteerd bestand OF een geselecteerde (sub)map. #}
|
||||
{% set activePath = opts.selectedDir|default('') != '' ? opts.selectedDir : opts.relFile %}
|
||||
{% set containsActive = n.is_dir and activePath != '' and activePath starts with (n.path ~ '/') %}
|
||||
{% if n.is_dir %}
|
||||
{% set icon = 'bi-folder-fill text-warning' %}
|
||||
{% elseif n.extension == 'md' %}
|
||||
{% set icon = 'bi-file-text text-primary' %}
|
||||
{% elseif n.extension == 'php' %}
|
||||
{% set icon = 'bi-file-code text-success' %}
|
||||
{% elseif n.extension == 'twig' %}
|
||||
{% set icon = 'bi-filetype-tfn text-info' %}
|
||||
{% elseif n.extension == 'json' %}
|
||||
{% set icon = 'bi-filetype-json text-secondary' %}
|
||||
{% elseif n.extension in ['css','scss'] %}
|
||||
{% set icon = 'bi-filetype-css text-info' %}
|
||||
{% elseif n.extension == 'js' %}
|
||||
{% set icon = 'bi-filetype-js text-warning' %}
|
||||
{% elseif n.extension == 'html' %}
|
||||
{% set icon = 'bi-file-earmark text-info' %}
|
||||
{% elseif n.extension in ['jpg','jpeg','png','gif','webp','svg'] %}
|
||||
{% set icon = 'bi-file-image text-info' %}
|
||||
{% elseif n.extension == 'pdf' %}
|
||||
{% set icon = 'bi-file-pdf text-danger' %}
|
||||
{% else %}
|
||||
{% set icon = 'bi-file text-muted' %}
|
||||
{% endif %}
|
||||
<li data-path="{{ n.path }}" data-isdir="{{ n.is_dir ? '1' : '0' }}">
|
||||
{% if n.is_dir %}
|
||||
{% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
||||
<div class="tree-dir-row" data-dir="{{ n.path }}">
|
||||
{# Chevron: expand/collapse only (zonder navigatie) #}
|
||||
<button type="button" class="tree-chevron-btn" data-bs-toggle="collapse" data-bs-target="#{{ collapseId }}" aria-expanded="{{ containsActive ? 'true' : 'false' }}" title="{{ ta.expand_collapse|default('Uitklappen/inklappen') }}" aria-label="{{ ta.expand_collapse|default('Uitklappen/inklappen') }}">
|
||||
<i class="bi {{ containsActive ? 'bi-chevron-down' : 'bi-chevron-right' }} tree-chevron"></i>
|
||||
</button>
|
||||
{# Mapnaam: selecteer de map. Voor content gaat dit naar ?dir= (map-detail paneel rechts);
|
||||
voor plugins/themes is het een niet-navigerende toggle (legacy gedrag). #}
|
||||
{% if opts.treeDirSelectRoute %}
|
||||
<a href="{{ opts.treeDirSelectRoute }}{% if '?' in opts.treeDirSelectRoute %}&{% else %}?{% endif %}dir={{ n.path|url_encode }}" class="tree-link tree-name {{ containsActive ? 'is-active' : '' }} {{ opts.selectedDir == n.path ? 'is-selected' : '' }}" title="{{ n.path }}">
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}" data-bs-toggle="collapse" data-bs-target="#{{ collapseId }}" aria-expanded="{{ containsActive ? 'true' : 'false' }}" title="{{ n.path }}">
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if opts.treeDirActions %}
|
||||
<span class="tree-dir-actions">
|
||||
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="{{ opts.newFileModalId|default('#newFileModal') }}" data-in-dir="{{ n.path }}">
|
||||
<i class="bi bi-file-earmark-plus"></i>
|
||||
</button>
|
||||
{% if opts.newDirModalId is defined and opts.newDirModalId %}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="{{ opts.newDirModalId }}" data-in-dir="{{ n.path }}">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if opts.treeDirRenameRoute %}
|
||||
<a href="{{ opts.treeDirRenameRoute }}{% if '?' in opts.treeDirRenameRoute %}&{% else %}?{% endif %}dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="{{ collapseId }}"
|
||||
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
|
||||
{% if n.children is not empty %}
|
||||
{{ macros.tree(n.children, opts) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ opts.treeRouteBase }}?{{ opts.treeRouteParams }}file={{ n.path|url_encode }}"
|
||||
class="tree-link {{ isActive ? 'is-active' : '' }}"
|
||||
title="{{ n.path }}">
|
||||
<span class="tree-chevron-spacer"></span>
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
{% if n.extension in opts.editableExts %}
|
||||
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<span class="tree-file-actions">
|
||||
{% if n.path not in (opts.treeHideActionsFor ?? []) %}
|
||||
{% set showFileMove = opts.treeFileMove is defined ? opts.treeFileMove : true %}
|
||||
{% if showFileMove %}
|
||||
<a href="{{ opts.treeMoveRoute }}?{{ opts.treeMoveParams }}file={{ n.path|url_encode }}"
|
||||
class="tree-move-btn" title="{{ ta.rename|default('Hernoemen/Verplaatsen') }}" aria-label="{{ ta.rename|default('Hernoemen/Verplaatsen') }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}" data-path="{{ n.path }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
{% import _self as tree_macros %}
|
||||
|
||||
{# Render the tree inside the standard sidebar card. The container keeps the
|
||||
generic id "editorFileTree" so shared CSS/JS selectors work everywhere. #}
|
||||
<div class="plugin-file-tree">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ treeHeader|default('Bestanden') }}</span>
|
||||
</div>
|
||||
<div class="card-body p-2" id="editorFileTree"
|
||||
data-csrf="{{ csrf_token|default('') }}"
|
||||
data-scope="{{ treeScope|default('content') }}"
|
||||
data-move-url="{{ treeMoveUrl|default('') }}"
|
||||
data-delete-base="{{ treeDeleteBase|default('') }}"
|
||||
data-new-file-modal="{{ newFileModalId|default('#newFileModal') }}"
|
||||
data-new-dir-modal="{{ newDirModalId|default('#newDirModal') }}"
|
||||
data-scope-param="{{ treeScopeParam|default('') }}"
|
||||
data-edit-base="{{ treeRouteBase|default('') }}">
|
||||
{% if treeDirSelectRoute is defined and treeDirSelectRoute is not empty %}
|
||||
{# Klikbare content-root: selecteert de root map (?dir= leeg).
|
||||
data-dir="" maakt de root ook een drag-drop doel (lege string = base dir). #}
|
||||
<a href="{{ treeDirSelectRoute }}" class="tree-link tree-root-link {{ selectedDir|default('') == '' ? 'is-selected' : '' }}" data-dir="" title="content">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
<span class="text-truncate">{{ treeRootLabel|default('content') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if files is empty %}
|
||||
<div class="small text-muted p-2">{{ treeEmptyText|default('Geen bestanden gevonden.') }}</div>
|
||||
{% else %}
|
||||
{{ tree_macros.tree(files, {
|
||||
'relFile': relFile,
|
||||
'selectedDir': selectedDir|default(''),
|
||||
'editableExts': editableExts,
|
||||
'treeIdPrefix': treeIdPrefix,
|
||||
'treeRouteBase': treeRouteBase,
|
||||
'treeRouteParams': treeRouteParams,
|
||||
'treeMoveRoute': treeMoveRoute,
|
||||
'treeMoveParams': treeMoveParams,
|
||||
'treeDirActions': treeDirActions is defined ? treeDirActions : true,
|
||||
'treeDirSelectRoute': treeDirSelectRoute|default(''),
|
||||
'treeDirRenameRoute': treeDirRenameRoute|default(''),
|
||||
'treeFileMove': treeFileMove is defined ? treeFileMove : true,
|
||||
'treeHideActionsFor': treeHideActionsFor|default([]),
|
||||
'newFileModalId': newFileModalId|default('#newFileModal'),
|
||||
'newDirModalId': newDirModalId|default(''),
|
||||
}) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/admin/assets/js/tree-interactions.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var tree = document.getElementById('editorFileTree');
|
||||
if (!tree) return;
|
||||
|
||||
function findToggle(collapseEl) {
|
||||
var id = collapseEl.id;
|
||||
if (!id) return null;
|
||||
return tree.querySelector('[data-bs-target="#' + id + '"]');
|
||||
}
|
||||
|
||||
function expand(btn) {
|
||||
var icon = btn.querySelector('.tree-chevron');
|
||||
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
|
||||
}
|
||||
|
||||
function collapse(btn) {
|
||||
var icon = btn.querySelector('.tree-chevron');
|
||||
if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); }
|
||||
}
|
||||
|
||||
tree.addEventListener('shown.bs.collapse', function (e) {
|
||||
var btn = findToggle(e.target);
|
||||
if (btn) expand(btn);
|
||||
});
|
||||
tree.addEventListener('hidden.bs.collapse', function (e) {
|
||||
var btn = findToggle(e.target);
|
||||
if (btn) collapse(btn);
|
||||
});
|
||||
|
||||
// Set initial state for folders that are open on page load
|
||||
Array.prototype.forEach.call(tree.querySelectorAll('.tree-collapse.show'), function (c) {
|
||||
var btn = findToggle(c);
|
||||
if (btn) expand(btn);
|
||||
});
|
||||
|
||||
// Make all tree <li> nodes draggable (for drag-and-drop move)
|
||||
Array.prototype.forEach.call(tree.querySelectorAll('li[data-path]'), function (li) {
|
||||
li.setAttribute('draggable', 'true');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -1,19 +1,22 @@
|
||||
{# Reusable media modal, included by admin.twig when needs_editor is true.
|
||||
The editor toolbar "media" button opens this modal. It fetches the list
|
||||
of media files from /admin/media-list (JSON) and, on click, inserts a
|
||||
snippet into the active CodeMirror editor at the cursor.
|
||||
of media files from /admin/media-list (JSON) and renders them as a
|
||||
collapsible file tree (consistent with the content/plugin/theme editors).
|
||||
On click of a file node, a snippet is inserted into the active CodeMirror
|
||||
editor at the cursor.
|
||||
|
||||
Scope:
|
||||
- Default (content editor): fetches /admin/media-list
|
||||
which scans content/ (URLs are prefixed with /-media/).
|
||||
- Plugin editor (mediaPluginName set): fetches /admin/media-list?plugin=<name>
|
||||
which scans plugins/<name>/assets/ instead of content/.
|
||||
- Theme editor (mediaThemeName set): fetches /admin/media-list?theme=<name>
|
||||
which scans themes/<name>/assets/ instead of content/.
|
||||
|
||||
The snippet format depends on the editor's current mode (data-ext):
|
||||
- markdown (md): 
|
||||
- html/php: <img src="url" alt="name">
|
||||
- other (css, json, js, ...): the raw URL #}
|
||||
- markdown (md): 
|
||||
- html/php: <img src="url" alt="name">
|
||||
- other (css, json, js, ...): the raw URL #}
|
||||
<div class="modal fade" id="mediaModal" tabindex="-1" aria-labelledby="mediaModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
@@ -26,20 +29,20 @@
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="search" id="mediaModalFilter" class="form-control" placeholder="{{ ta.media_filter|default('Filter op bestandsnaam...') }}" autocomplete="off" aria-label="{{ ta.media_filter|default('Filter') }}">
|
||||
</div>
|
||||
<div id="mediaModalGrid" class="row g-3">
|
||||
<div class="col-12 text-center text-muted py-4" id="mediaModalLoading">
|
||||
<div id="mediaModalTree" class="media-tree-wrap">
|
||||
<div class="text-center text-muted py-4" id="mediaModalLoading">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span class="ms-2">{{ ta.media_loading|default('Media laden...') }}</span>
|
||||
</div>
|
||||
<div class="col-12 text-center text-muted py-4 d-none" id="mediaModalEmpty">
|
||||
<div class="text-center text-muted py-4 d-none" id="mediaModalEmpty">
|
||||
<i class="bi bi-image display-6 d-block mb-2"></i>
|
||||
{{ mediaEmptyText|default('Geen media bestanden gevonden in content/. Upload eerst bestanden via Media in het menu.') }}
|
||||
</div>
|
||||
<div class="col-12 text-center text-danger py-4 d-none" id="mediaModalError">
|
||||
<div class="text-center text-danger py-4 d-none" id="mediaModalError">
|
||||
<i class="bi bi-exclamation-triangle display-6 d-block mb-2"></i>
|
||||
<span id="mediaModalErrorText"></span>
|
||||
</div>
|
||||
{# Filled by JS #}
|
||||
{# Tree populated by JS #}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -49,6 +52,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Media modal tree — self-contained so the modal works on any editor page. */
|
||||
.media-tree-wrap { max-height: 60vh; overflow-y: auto; }
|
||||
.media-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||
.media-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||
.media-tree li { padding: 0; position: relative; }
|
||||
.media-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; cursor: pointer; }
|
||||
.media-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||
.media-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||
.media-tree .tree-chevron { flex: 0 0 auto; }
|
||||
.media-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||
.media-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.media-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||
.media-tree .media-thumb { width: 1.1rem; height: 1.1rem; object-fit: cover; flex: 0 0 auto; border-radius: .2rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -76,7 +95,6 @@
|
||||
}
|
||||
return '<a href="' + url + '">' + name + '</a>';
|
||||
}
|
||||
// css, scss, js, json, etc.: just insert the URL
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -85,86 +103,167 @@
|
||||
return map[ext] || 'markdown';
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
var grid = document.getElementById('mediaModalGrid');
|
||||
if (!grid) return;
|
||||
function iconFor(ext, isImage) {
|
||||
if (isImage) return 'bi-file-image text-info';
|
||||
if (ext === 'pdf') return 'bi-file-pdf text-danger';
|
||||
if (['mp4', 'webm', 'mov', 'ogg', 'avi'].indexOf(ext) !== -1) return 'bi-file-play text-info';
|
||||
if (['mp3', 'wav'].indexOf(ext) !== -1) return 'bi-file-music text-info';
|
||||
return 'bi-file text-muted';
|
||||
}
|
||||
|
||||
// Clear previous items (keep loading/empty/error placeholders)
|
||||
Array.prototype.forEach.call(grid.querySelectorAll('[data-media-item]'), function (el) { el.remove(); });
|
||||
|
||||
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
|
||||
var shown = 0;
|
||||
|
||||
items.forEach(function (item) {
|
||||
if (q && item.name.toLowerCase().indexOf(q) === -1) return;
|
||||
shown++;
|
||||
|
||||
var col = document.createElement('div');
|
||||
col.className = 'col-6 col-md-4 col-lg-3';
|
||||
col.setAttribute('data-media-item', '1');
|
||||
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card shadow-sm h-100';
|
||||
card.style.cursor = 'pointer';
|
||||
card.title = item.name + ' (' + item.size + ')';
|
||||
|
||||
if (item.is_image) {
|
||||
var img = document.createElement('img');
|
||||
img.src = item.url;
|
||||
img.className = 'card-img-top';
|
||||
img.style.objectFit = 'cover';
|
||||
img.style.height = '120px';
|
||||
img.loading = 'lazy';
|
||||
img.alt = item.name;
|
||||
card.appendChild(img);
|
||||
} else {
|
||||
var ph = document.createElement('div');
|
||||
ph.className = 'card-img-top d-flex align-items-center justify-content-center bg-light';
|
||||
ph.style.height = '120px';
|
||||
var icon = document.createElement('i');
|
||||
icon.className = 'bi bi-file-earmark-play display-6 text-muted';
|
||||
ph.appendChild(icon);
|
||||
card.appendChild(ph);
|
||||
}
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'card-body p-2';
|
||||
var label = document.createElement('p');
|
||||
label.className = 'card-text small text-muted text-truncate mb-0';
|
||||
label.textContent = item.name;
|
||||
body.appendChild(label);
|
||||
card.appendChild(body);
|
||||
|
||||
card.addEventListener('click', function () {
|
||||
var editor = window.codeMirrorEditor;
|
||||
if (!editor) { return; }
|
||||
var ext = (document.getElementById('editor-textarea') || {}).dataset;
|
||||
var fileExt = (ext && ext.ext) ? ext.ext : 'md';
|
||||
var mode = modeForExt(fileExt);
|
||||
var snippet = formatSnippet(item.url, item.name, item.extension, mode);
|
||||
editor.replaceSelection(snippet);
|
||||
editor.focus();
|
||||
// Close the modal
|
||||
var modalEl = document.getElementById('mediaModal');
|
||||
if (window.bootstrap && modalEl) {
|
||||
var inst = bootstrap.Modal.getInstance(modalEl);
|
||||
if (inst) inst.hide();
|
||||
// Build a nested tree from a flat list of {path, name, ...} items.
|
||||
function buildTree(flat) {
|
||||
var root = { name: '', path: '', children: {}, isDir: true };
|
||||
flat.forEach(function (item) {
|
||||
var parts = item.path.split('/');
|
||||
var node = root;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var part = parts[i];
|
||||
if (part === '') continue;
|
||||
var isLast = i === parts.length - 1;
|
||||
if (!node.children[part]) {
|
||||
node.children[part] = {
|
||||
name: part, path: parts.slice(0, i + 1).join('/'),
|
||||
children: {}, isDir: !isLast
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
col.appendChild(card);
|
||||
grid.appendChild(col);
|
||||
if (isLast) {
|
||||
node.children[part].item = item;
|
||||
node.children[part].isDir = false;
|
||||
}
|
||||
node = node.children[part];
|
||||
}
|
||||
});
|
||||
// Convert children maps to sorted arrays (dirs first, then alpha)
|
||||
function finalize(n) {
|
||||
var arr = Object.keys(n.children).map(function (k) { return n.children[k]; });
|
||||
arr.sort(function (a, b) {
|
||||
if (a.isDir && !b.isDir) return -1;
|
||||
if (!a.isDir && b.isDir) return 1;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
n.childArr = arr;
|
||||
arr.forEach(finalize);
|
||||
return n;
|
||||
}
|
||||
finalize(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
// Toggle empty placeholder
|
||||
var empty = document.getElementById('mediaModalEmpty');
|
||||
function matchesFilter(node, q) {
|
||||
if (!q) return true;
|
||||
if (node.item && node.item.name.toLowerCase().indexOf(q) !== -1) return true;
|
||||
if (node.childArr) {
|
||||
for (var i = 0; i < node.childArr.length; i++) {
|
||||
if (matchesFilter(node.childArr[i], q)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
var idCounter = 0;
|
||||
|
||||
function renderNode(node, container, q) {
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'media-tree';
|
||||
node.childArr.forEach(function (child) {
|
||||
if (!matchesFilter(child, q)) return;
|
||||
var li = document.createElement('li');
|
||||
if (child.isDir) {
|
||||
var collapseId = 'media-tree-' + (++idCounter);
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tree-link tree-toggle btn-toggle collapsed';
|
||||
btn.setAttribute('data-bs-toggle', 'collapse');
|
||||
btn.setAttribute('data-bs-target', '#' + collapseId);
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.title = child.path;
|
||||
btn.innerHTML = '<i class="bi bi-chevron-right tree-chevron"></i>' +
|
||||
'<i class="bi bi-folder-fill text-warning"></i>' +
|
||||
'<span class="text-truncate">' + escapeHtml(child.name) + '</span>';
|
||||
li.appendChild(btn);
|
||||
var wrap = document.createElement('div');
|
||||
wrap.id = collapseId;
|
||||
wrap.className = 'tree-collapse collapse';
|
||||
renderNode(child, wrap, q);
|
||||
li.appendChild(wrap);
|
||||
} else {
|
||||
var item = child.item;
|
||||
var a = document.createElement('a');
|
||||
a.className = 'tree-link';
|
||||
a.title = item.path + ' (' + item.size + ')';
|
||||
var thumb = '';
|
||||
if (item.is_image) {
|
||||
thumb = '<img class="media-thumb" src="' + item.url + '" alt="" loading="lazy">';
|
||||
} else {
|
||||
thumb = '<i class="bi ' + iconFor(item.extension, item.is_image) + '"></i>';
|
||||
}
|
||||
a.innerHTML = '<span class="tree-chevron-spacer"></span>' + thumb +
|
||||
'<span class="text-truncate">' + escapeHtml(item.name) + '</span>' +
|
||||
'<span class="badge bg-secondary badge-ext">' + item.extension.toUpperCase() + '</span>';
|
||||
a.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
insertItem(item);
|
||||
});
|
||||
li.appendChild(a);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
});
|
||||
container.appendChild(ul);
|
||||
}
|
||||
|
||||
function insertItem(item) {
|
||||
var editor = window.codeMirrorEditor;
|
||||
if (!editor) return;
|
||||
var ext = (document.getElementById('editor-textarea') || {}).dataset;
|
||||
var fileExt = (ext && ext.ext) ? ext.ext : 'md';
|
||||
var mode = modeForExt(fileExt);
|
||||
var snippet = formatSnippet(item.url, item.name, item.extension, mode);
|
||||
editor.replaceSelection(snippet);
|
||||
editor.focus();
|
||||
var modalEl = document.getElementById('mediaModal');
|
||||
if (window.bootstrap && modalEl) {
|
||||
var inst = bootstrap.Modal.getInstance(modalEl);
|
||||
if (inst) inst.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
var wrap = document.getElementById('mediaModalTree');
|
||||
if (!wrap) return;
|
||||
// Clear previous tree (keep placeholders)
|
||||
Array.prototype.forEach.call(wrap.querySelectorAll('.media-tree'), function (el) { el.remove(); });
|
||||
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
|
||||
var tree = buildTree(items);
|
||||
var shown = tree.childArr.length;
|
||||
if (shown > 0) {
|
||||
// When filtering, auto-expand all dirs by rendering without collapsed class
|
||||
renderNode(tree, wrap, q);
|
||||
}
|
||||
var loading = document.getElementById('mediaModalLoading');
|
||||
var empty = document.getElementById('mediaModalEmpty');
|
||||
if (loading) loading.classList.add('d-none');
|
||||
if (empty) empty.classList.toggle('d-none', shown > 0);
|
||||
// When filtering, expand all folders for visibility
|
||||
if (q) {
|
||||
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-collapse'), function (el) { el.classList.add('show'); });
|
||||
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-toggle.btn-toggle'), function (el) {
|
||||
el.classList.remove('collapsed');
|
||||
el.setAttribute('aria-expanded', 'true');
|
||||
var icon = el.querySelector('.tree-chevron');
|
||||
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (loaded) { renderGrid(); return; }
|
||||
if (loaded) { renderTree(); return; }
|
||||
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
||||
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
|
||||
var url = '/admin/media-list';
|
||||
@@ -178,7 +277,7 @@
|
||||
.then(function (data) {
|
||||
items = Array.isArray(data) ? data : [];
|
||||
loaded = true;
|
||||
renderGrid();
|
||||
renderTree();
|
||||
})
|
||||
.catch(function (err) {
|
||||
var loading = document.getElementById('mediaModalLoading');
|
||||
@@ -190,15 +289,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Load when the modal is first shown
|
||||
var modalEl = document.getElementById('mediaModal');
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('show.bs.modal', load);
|
||||
}
|
||||
// Filter as the user types
|
||||
var filter = document.getElementById('mediaModalFilter');
|
||||
if (filter) {
|
||||
filter.addEventListener('input', renderGrid);
|
||||
filter.addEventListener('input', renderTree);
|
||||
}
|
||||
|
||||
// Chevron rotation: swap icon class between chevron-right and chevron-down
|
||||
var mediaTreeWrap = document.getElementById('mediaModalTree');
|
||||
if (mediaTreeWrap) {
|
||||
mediaTreeWrap.addEventListener('shown.bs.collapse', function (e) {
|
||||
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
|
||||
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); } }
|
||||
});
|
||||
mediaTreeWrap.addEventListener('hidden.bs.collapse', function (e) {
|
||||
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
|
||||
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); } }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> {{ ta.rename|default('Hernoemen') }}
|
||||
</button>
|
||||
<a href="/admin/content-list?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
{% block title %}{{ fileName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
{% include 'pages/_editor-styles.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
||||
|
||||
@@ -11,12 +15,8 @@
|
||||
<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>
|
||||
<label class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||
<p class="form-control-plaintext mb-0"><code>{{ fileName }}</code></p>
|
||||
</div>
|
||||
{% if isEditable %}
|
||||
<div class="col-md-4">
|
||||
@@ -29,31 +29,29 @@
|
||||
</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">
|
||||
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
||||
<div class="plugin-checkbox-group">
|
||||
{% 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>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if isEditable and (currentAuthorName or currentAuthorEmail or currentCreated) %}
|
||||
{% if isEditable and (currentCreated or currentEdited) %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorName }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted"><i class="bi bi-envelope"></i> {{ ta.author_email|default('Auteur e-mail') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorEmail }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted"><i class="bi bi-calendar"></i> {{ ta.created|default('Aangemaakt') }}</label>
|
||||
<label class="form-label text-muted"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if isEditable %}
|
||||
@@ -62,25 +60,31 @@
|
||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
||||
<button type="submit" form="editor-form" 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') }}">
|
||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" 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-list?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
||||
<a href="/admin/content" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
||||
<form method="POST" action="/admin/content-delete" class="ms-auto" 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_token }}">
|
||||
<input type="hidden" name="file" value="{{ file }}">
|
||||
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||
</button>
|
||||
</form>
|
||||
</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;
|
||||
|
||||
@@ -94,10 +98,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
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 () {
|
||||
|
||||
@@ -2,123 +2,14 @@
|
||||
|
||||
{% block title %}{{ ta.content_editor|default('Content editor') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||
|
||||
{# Recursively render the content file tree as a nested, collapsible list. #}
|
||||
{% macro tree(nodes, relFile, editableExts, csrfToken) %}
|
||||
{% import _self as macros %}
|
||||
<ul class="plugin-tree">
|
||||
{% for n in nodes %}
|
||||
{% set isActive = n.path == relFile %}
|
||||
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
|
||||
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'html' ? 'bi-file-earmark text-info' : (n.extension in ['jpg','jpeg','png','gif','webp','svg'] ? 'bi-file-image text-info' : (n.extension == 'pdf' ? 'bi-file-pdf text-danger' : 'bi-file text-muted'))))) %}
|
||||
<li>
|
||||
{% if n.is_dir %}
|
||||
{% set collapseId = 'content-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
||||
<div class="tree-dir-row">
|
||||
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ collapseId }}"
|
||||
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
|
||||
title="{{ n.path }}">
|
||||
<i class="bi bi-chevron-down tree-chevron"></i>
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
</button>
|
||||
<span class="tree-dir-actions">
|
||||
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ n.path }}">
|
||||
<i class="bi bi-file-earmark-plus"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ n.path }}">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</button>
|
||||
<a href="/admin/content-dir-rename-in?dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/content-dir-delete-in" 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="{{ csrfToken }}">
|
||||
<input type="hidden" name="dir" value="{{ n.path }}">
|
||||
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
<div id="{{ collapseId }}"
|
||||
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
|
||||
{% if n.children is not empty %}
|
||||
{{ macros.tree(n.children, relFile, editableExts, csrfToken) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/admin/content?file={{ n.path|url_encode }}"
|
||||
class="tree-link {{ isActive ? 'is-active' : '' }}"
|
||||
title="{{ n.path }}">
|
||||
<span class="tree-chevron-spacer"></span>
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
{% if n.extension in editableExts %}
|
||||
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/admin/content-file-move?file={{ n.path|url_encode }}"
|
||||
class="tree-move-btn" title="{{ ta.move|default('Verplaatsen') }}" aria-label="{{ ta.move|default('Verplaatsen') }}">
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/content-file-delete" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="file" value="{{ n.path }}">
|
||||
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
{% import _self as tree_macros %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||
{% include 'pages/_editor-styles.twig' %}
|
||||
<style>
|
||||
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
|
||||
.plugin-file-tree { width: 300px; flex: 0 0 300px; }
|
||||
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
|
||||
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||
.plugin-tree li { padding: 0; position: relative; }
|
||||
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
|
||||
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
|
||||
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
|
||||
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
|
||||
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
|
||||
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
|
||||
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
|
||||
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
|
||||
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
|
||||
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||
/* Directory action buttons (new file/dir, rename, delete) */
|
||||
.tree-dir-row { position: relative; }
|
||||
.tree-dir-row .tree-toggle { padding-right: 7rem; }
|
||||
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
|
||||
.tree-dir-actions .btn-link { color: #6c757d; }
|
||||
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
|
||||
.tree-dir-actions .tree-delete-btn { color: #dc3545; }
|
||||
.tree-dir-actions .tree-delete-btn:hover { color: #b02a37; }
|
||||
/* Git status badge */
|
||||
/* Git status badge (content editor only) */
|
||||
.git-status { font-size: .75rem; }
|
||||
.git-status .badge { font-weight: 400; }
|
||||
@media (max-width: 768px) {
|
||||
.plugin-editor-layout { flex-direction: column; }
|
||||
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||
.plugin-file-tree .card-body { max-height: 260px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -126,58 +17,23 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.content_editor|default('Content editor') }}</h2>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{# Fase 5: backup/git integratie in zijbalk header #}
|
||||
{% if gitStatus.available %}
|
||||
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
|
||||
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
|
||||
</a>
|
||||
{% if gitStatus.dirty %}
|
||||
<form method="POST" action="/admin/content-git-commit" class="d-inline" onsubmit="return confirm('{{ ta.git_commit_confirm|default('Wijzigingen committen naar git?') }}')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm" title="{{ ta.git_commit|default('Git commit') }}">
|
||||
<i class="bi bi-git"></i> {{ ta.git_commit|default('Commit') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<form method="POST" action="/admin/content-git-init" class="d-inline" onsubmit="return confirm('{{ ta.git_init_confirm|default('Git repository initialiseren in content/?') }}')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{{ ta.git_init|default('Git init') }}">
|
||||
<i class="bi bi-git"></i> {{ ta.git_init|default('Git init') }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
|
||||
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Backup integratie in zijbalk header (git wordt later een systeem plugin) #}
|
||||
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
|
||||
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="newFileBtnTop" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||
</button>
|
||||
<a href="/admin/content-list" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-list"></i> {{ ta.list_view|default('Lijst weergave') }}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Fase 5: git status badge bovenaan #}
|
||||
{% if gitStatus.available %}
|
||||
<div class="git-status mb-2 d-flex gap-2 align-items-center flex-wrap">
|
||||
<span class="badge bg-secondary"><i class="bi bi-git"></i> {{ gitStatus.branch|default('git') }}</span>
|
||||
{% if gitStatus.dirty %}
|
||||
<span class="badge bg-warning text-dark">{{ ta.git_dirty|default('niet-committed wijzigingen') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">{{ ta.git_clean|default('schone werkmap') }}</span>
|
||||
{% endif %}
|
||||
{% if gitStatus.lastCommit %}<span class="text-muted small">{{ ta.git_last_commit|default('laatste commit:') }} {{ gitStatus.lastCommit }}</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Upload form (collapsed by default) #}
|
||||
<div class="collapse mb-3" id="contentUploadForm">
|
||||
<div class="card shadow-sm">
|
||||
@@ -198,25 +54,111 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plugin-editor-layout">
|
||||
{# File tree sidebar #}
|
||||
<div class="plugin-file-tree">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.content_files|default('Content bestanden') }}</span>
|
||||
</div>
|
||||
<div class="card-body p-2" id="pluginFileTree">
|
||||
{% if files is empty %}
|
||||
<div class="small text-muted p-2">{{ ta.content_no_files|default('Geen bestanden gevonden.') }}</div>
|
||||
{% else %}
|
||||
{{ tree_macros.tree(files, relFile, editableExts, csrf_token) }}
|
||||
<div class="editor-layout">
|
||||
{# File tree sidebar (shared partial) #}
|
||||
{% include 'pages/_file-tree.twig' with {
|
||||
'files': files,
|
||||
'relFile': relFile,
|
||||
'editableExts': editableExts,
|
||||
'treeIdPrefix': 'content-tree',
|
||||
'treeHeader': ta.content_files|default('Content bestanden'),
|
||||
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
|
||||
'treeRouteBase': '/admin/content',
|
||||
'treeRouteParams': '',
|
||||
'treeMoveRoute': '/admin/content-file-move',
|
||||
'treeMoveParams': '',
|
||||
'treeDirActions': true,
|
||||
'treeDirSelectRoute': '/admin/content',
|
||||
'treeDirRenameRoute': '',
|
||||
'treeFileMove': false,
|
||||
'treeHideActionsFor': [],
|
||||
'selectedDir': selectedDir|default(''),
|
||||
'treeRootLabel': 'content',
|
||||
'treeScope': 'content',
|
||||
'treeMoveUrl': '/admin/tree-move',
|
||||
'treeDeleteBase': '/admin/content-file-delete',
|
||||
'treeScopeParam': '',
|
||||
'newFileModalId': '#newFileModal',
|
||||
'newDirModalId': '#newDirModal',
|
||||
} %}
|
||||
|
||||
{# Editor / map-detail main panel #}
|
||||
<div class="editor-main">
|
||||
{% if showMapPaneel %}
|
||||
{# ── Map detail paneel ( rechts) ── tonen wanneer een map is geselecteerd #}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-folder2-open"></i> content</a></li>
|
||||
{% if selectedDir %}
|
||||
{% set crumbPath = '' %}
|
||||
{% for part in selectedDir|split('/') %}
|
||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||
{% if loop.last %}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item"><a href="/admin/content?dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<i class="bi bi-folder-fill text-warning"></i>
|
||||
<span class="fw-semibold">{{ selectedDirIsRoot ? 'content' : selectedDirName }}</span>
|
||||
{% if not selectedDirIsRoot %}
|
||||
<span class="badge bg-light text-dark ms-1">content/{{ selectedDir }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Mapnaam bewerkbaar + Opslaan = hernoem (geen apart potlood-icoon in de boom meer) #}
|
||||
<form method="POST" action="/admin/content?{% if selectedDir %}dir={{ selectedDir|url_encode }}{% endif %}" id="dir-rename-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="rename_dir">
|
||||
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||
<label for="dirname" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" id="dirname" name="newname" value="{{ selectedDirIsRoot ? '' : selectedDirName }}" placeholder="{{ ta.folder_name_placeholder|default('mapnaam') }}" {{ selectedDirIsRoot ? 'disabled' : '' }}>
|
||||
<button type="submit" class="btn btn-primary" {{ selectedDirIsRoot ? 'disabled' : '' }}>
|
||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">{{ ta.folder_rename_help|default('Bewerk de mapnaam en klik opslaan om te hernoemen.') }}</div>
|
||||
</form>
|
||||
|
||||
{# Editor main panel #}
|
||||
<div class="plugin-editor-main">
|
||||
{# Map acties: nieuw bestand / nieuwe map / upload — opereren op de geselecteerde map #}
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}" id="mapPaneelNewFileBtn">
|
||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}" id="mapPaneelNewDirBtn">
|
||||
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm" id="mapPaneelUploadBtn">
|
||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||
</button>
|
||||
{% if not selectedDirIsRoot %}
|
||||
<form method="POST" action="/admin/content-dir-delete-in" 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_token }}">
|
||||
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Korte inhoud-samenvatting van de geselecteerde map #}
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-files"></i>
|
||||
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% if relFile %}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
@@ -243,13 +185,16 @@
|
||||
{# Metadata row: filename display, layout selector, plugins #}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||
<p class="form-control-plaintext form-control-sm mb-0"><code>{{ fileName }}</code></p>
|
||||
<label for="new_filename" class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" id="new_filename" name="new_filename" value="{{ fileBaseName }}" required>
|
||||
<span class="input-group-text">.{{ fileExt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if fileExt == 'md' %}
|
||||
<div class="col-md-4">
|
||||
<label for="layout" class="form-label">{{ ta.template_layout|default('Sjabloon / Layout') }} <small class="text-muted">({{ activeThemeName|default('default') }})</small></label>
|
||||
<select class="form-select form-select-sm" id="layout" name="layout">
|
||||
<option value="">{{ ta.theme_default|default('Thema standaard') }}</option>
|
||||
{% for key, layoutFile in themeLayouts %}
|
||||
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||
{% endfor %}
|
||||
@@ -257,49 +202,51 @@
|
||||
</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">
|
||||
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
||||
<div class="plugin-checkbox-group">
|
||||
{% 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>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if currentAuthorName or currentAuthorEmail or currentCreated %}
|
||||
{# Created / edited timestamps (read-only, auto-filled) #}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorName }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted"><i class="bi bi-envelope"></i> {{ ta.author_email|default('Auteur e-mail') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorEmail }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted"><i class="bi bi-calendar"></i> {{ ta.created|default('Aangemaakt') }}</label>
|
||||
<label class="form-label text-muted small"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted small"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
|
||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
||||
<button type="submit" form="editor-form" 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>
|
||||
<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') }}">
|
||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" 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>
|
||||
<a href="/admin/content-list?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary">{{ ta.list_view|default('Lijst weergave') }}</a>
|
||||
<form method="POST" action="/admin/content-file-delete" class="ms-auto" 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_token }}">
|
||||
<input type="hidden" name="file" value="{{ relFile }}">
|
||||
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
@@ -307,11 +254,12 @@
|
||||
{% if relFile %}
|
||||
{{ ta.content_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
|
||||
{% else %}
|
||||
{{ ta.content_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
|
||||
{{ ta.content_select_file|default('Selecteer een bestand of map uit de zijbalk.') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}{# end showMapPaneel #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +270,7 @@
|
||||
<form method="POST" action="/admin/content">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="new_file">
|
||||
<input type="hidden" name="in_dir" id="newFileDir" value="">
|
||||
<input type="hidden" name="in_dir" id="newFileDir" value="" data-in-dir-field>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.new_file_title|default('Nieuw bestand aanmaken') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
@@ -359,7 +307,7 @@
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/content-dir-create-in">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="in_dir" id="newDirInDir" value="">
|
||||
<input type="hidden" name="in_dir" id="newDirInDir" value="" data-in-dir-field>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newDirModalLabel"><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" aria-label="Close"></button>
|
||||
@@ -381,17 +329,40 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Pass the in-dir value from the tree button to the modal hidden fields
|
||||
// Mapselectie + upload/new file/new dir binding aan de geselecteerde map.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var newFileModal = document.getElementById('newFileModal');
|
||||
var newDirModal = document.getElementById('newDirModal');
|
||||
var newFileBtnTop = document.getElementById('newFileBtnTop');
|
||||
var newDirBtnTop = document.getElementById('newDirBtnTop');
|
||||
var uploadDir = document.getElementById('contentUploadDir');
|
||||
|
||||
// Track the selected directory (server-side via ?dir=). This drives the
|
||||
// top toolbar buttons and the upload form target.
|
||||
var selectedDir = '{{ selectedDir|default('') }}';
|
||||
function updateTopButtons(dir) {
|
||||
selectedDir = dir || '';
|
||||
if (newFileBtnTop) newFileBtnTop.setAttribute('data-in-dir', selectedDir);
|
||||
if (newDirBtnTop) newDirBtnTop.setAttribute('data-in-dir', selectedDir);
|
||||
if (uploadDir) uploadDir.value = selectedDir;
|
||||
}
|
||||
updateTopButtons(selectedDir);
|
||||
|
||||
var tree = document.getElementById('editorFileTree');
|
||||
if (tree) {
|
||||
// Chevron-kliks mogen niet doorbubbellen naar de mapnaam-link (die navigeert).
|
||||
tree.addEventListener('click', function (e) {
|
||||
var chevronBtn = e.target.closest('.tree-chevron-btn');
|
||||
if (chevronBtn) { e.stopPropagation(); }
|
||||
});
|
||||
}
|
||||
|
||||
if (newFileModal) {
|
||||
newFileModal.addEventListener('show.bs.modal', function (event) {
|
||||
var trigger = event.relatedTarget;
|
||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : '';
|
||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
||||
var hidden = document.getElementById('newFileDir');
|
||||
if (hidden) hidden.value = inDir || '';
|
||||
// Show the in-dir prefix as a hint in the placeholder
|
||||
var input = document.getElementById('newFilenameInput');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
@@ -402,7 +373,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (newDirModal) {
|
||||
newDirModal.addEventListener('show.bs.modal', function (event) {
|
||||
var trigger = event.relatedTarget;
|
||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : '';
|
||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
||||
var hidden = document.getElementById('newDirInDir');
|
||||
if (hidden) hidden.value = inDir;
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }}</h2>
|
||||
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen / hernoemen') }}</h2>
|
||||
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||
</a>
|
||||
@@ -15,22 +15,29 @@
|
||||
<input type="hidden" name="file" value="{{ relFile }}">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong>
|
||||
{{ ta.move_prefix|default('Verplaats / hernoem') }} <strong>{{ itemName }}{{ itemExt }}</strong>
|
||||
{% if itemDir %}<span class="text-muted">(content/{{ itemDir }})</span>{% else %}<span class="text-muted">(content/)</span>{% endif %}
|
||||
{{ ta.move_to|default('naar:') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_name" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="new_name" name="new_name" value="{{ itemName }}" required>
|
||||
<span class="input-group-text">{{ itemExt }}</span>
|
||||
</div>
|
||||
<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="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }} <small class="text-muted">(content/)</small></label>
|
||||
<select class="form-select" id="destination" name="destination" required>
|
||||
{% for directory in directories %}
|
||||
<option value="{{ directory }}">{{ directory == '' ? '(content root)' : directory }}</option>
|
||||
<option value="{{ directory }}" {{ directory == itemDir ? 'selected' : '' }}>{{ directory == '' ? '(content root)' : 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') }}
|
||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||
</button>
|
||||
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}
|
||||
</button>
|
||||
<a href="/admin/content-list?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,99 +2,10 @@
|
||||
|
||||
{% block title %}{{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||
|
||||
{# Recursively render the plugin file tree as a nested, collapsible list.
|
||||
A folder is expanded by default when the currently selected file lives
|
||||
somewhere inside it, so the user always sees where their file is. #}
|
||||
{% macro tree(nodes, pluginName, relFile, editableExts, csrfToken) %}
|
||||
{% import _self as macros %}
|
||||
<ul class="plugin-tree">
|
||||
{% for n in nodes %}
|
||||
{% set isActive = n.path == relFile %}
|
||||
{# A folder contains the active file when relFile starts with n.path + '/' #}
|
||||
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
|
||||
{# Icon per type #}
|
||||
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'json' ? 'bi-filetype-json text-secondary' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension in ['css','scss'] ? 'bi-filetype-css text-info' : (n.extension == 'js' ? 'bi-filetype-js text-warning' : (n.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted')))))) %}
|
||||
<li>
|
||||
{% if n.is_dir %}
|
||||
{% set collapseId = 'plugin-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
||||
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ collapseId }}"
|
||||
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
|
||||
title="{{ n.path }}">
|
||||
<i class="bi bi-chevron-down tree-chevron"></i>
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
</button>
|
||||
<div id="{{ collapseId }}"
|
||||
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
|
||||
{% if n.children is not empty %}
|
||||
{{ macros.tree(n.children, pluginName, relFile, editableExts, csrfToken) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ n.path|url_encode }}"
|
||||
class="tree-link {{ isActive ? 'is-active' : '' }}"
|
||||
title="{{ n.path }}">
|
||||
<span class="tree-chevron-spacer"></span>
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
{% if n.extension in editableExts %}
|
||||
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/admin/plugins-file-move?plugin={{ pluginName|url_encode }}&file={{ n.path|url_encode }}"
|
||||
class="tree-move-btn" title="{{ ta.move|default('Verplaatsen') }}" aria-label="{{ ta.move|default('Verplaatsen') }}">
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/plugins-file-delete" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||
<input type="hidden" name="file" value="{{ n.path }}">
|
||||
<button type="submit" class="btn btn-link btn-sm text-danger p-0 ms-1 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
{% import _self as tree_macros %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||
<style>
|
||||
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
|
||||
.plugin-file-tree { width: 280px; flex: 0 0 280px; }
|
||||
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
|
||||
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||
.plugin-tree li { padding: 0; position: relative; }
|
||||
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
|
||||
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
|
||||
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
|
||||
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Move and delete buttons: always visible, right-aligned, normal icon size */
|
||||
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
|
||||
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
|
||||
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
|
||||
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
|
||||
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
|
||||
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
|
||||
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||
@media (max-width: 768px) {
|
||||
.plugin-editor-layout { flex-direction: column; }
|
||||
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||
.plugin-file-tree .card-body { max-height: 260px; }
|
||||
}
|
||||
</style>
|
||||
{% include 'pages/_editor-styles.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -107,6 +18,9 @@
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
|
||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.plugin_new_file|default('Nieuw bestand') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||
</a>
|
||||
@@ -134,25 +48,91 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plugin-editor-layout">
|
||||
{# File tree sidebar #}
|
||||
<div class="plugin-file-tree">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.plugin_files|default('Plugin bestanden') }}</span>
|
||||
</div>
|
||||
<div class="card-body p-2" id="pluginFileTree">
|
||||
{% if files is empty %}
|
||||
<div class="small text-muted p-2">{{ ta.plugin_no_files|default('Geen bestanden gevonden.') }}</div>
|
||||
{% else %}
|
||||
{{ tree_macros.tree(files, pluginName, relFile, editableExts, csrf_token) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-layout">
|
||||
{# File tree sidebar (shared partial) #}
|
||||
{% include 'pages/_file-tree.twig' with {
|
||||
'files': files,
|
||||
'relFile': relFile,
|
||||
'editableExts': editableExts,
|
||||
'treeIdPrefix': 'plugin-tree',
|
||||
'treeHeader': ta.plugin_files|default('Plugin bestanden'),
|
||||
'treeEmptyText': ta.plugin_no_files|default('Geen bestanden gevonden.'),
|
||||
'treeRouteBase': '/admin/plugins-edit',
|
||||
'treeRouteParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||
'treeMoveRoute': '/admin/plugins-file-move',
|
||||
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||
'treeDirActions': true,
|
||||
'treeHideActionsFor': [],
|
||||
'treeDirSelectRoute': '/admin/plugins-edit?plugin=' ~ pluginName|url_encode,
|
||||
'treeDirRenameRoute': '/admin/plugins-dir-rename-in?plugin=' ~ pluginName|url_encode,
|
||||
'treeRootLabel': pluginName,
|
||||
'treeScope': 'plugin',
|
||||
'treeMoveUrl': '/admin/tree-move',
|
||||
'treeDeleteBase': '/admin/plugins-file-delete',
|
||||
'treeScopeParam': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||
'selectedDir': selectedDir|default(''),
|
||||
'newFileModalId': '#newFileModal',
|
||||
'newDirModalId': '#newDirModal',
|
||||
} %}
|
||||
|
||||
{# Editor main panel #}
|
||||
<div class="plugin-editor-main">
|
||||
<div class="editor-main">
|
||||
{% if showMapPaneel %}
|
||||
{# Map detail paneel — tonen wanneer een map is geselecteerd #}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}"><i class="bi bi-folder2-open"></i> {{ pluginName }}</a></li>
|
||||
{% if selectedDir %}
|
||||
{% set crumbPath = '' %}
|
||||
{% for part in selectedDir|split('/') %}
|
||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||
{% if loop.last %}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<i class="bi bi-folder-fill text-warning"></i>
|
||||
<span class="fw-semibold">{{ selectedDirIsRoot ? pluginName : selectedDirName }}</span>
|
||||
{% if not selectedDirIsRoot %}
|
||||
<span class="badge bg-light text-dark ms-1">{{ pluginName }}/{{ selectedDir }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Map acties: nieuw bestand / nieuwe map / upload / verwijder #}
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}">
|
||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}">
|
||||
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||
</button>
|
||||
{% if not selectedDirIsRoot %}
|
||||
<form method="POST" action="/admin/plugins-dir-delete-in" 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_token }}">
|
||||
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-files"></i>
|
||||
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% if relFile %}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
@@ -182,13 +162,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" form="editor-form" 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>
|
||||
<form method="POST" action="/admin/plugins-file-delete" class="ms-auto" 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_token }}">
|
||||
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||
<input type="hidden" name="file" value="{{ relFile }}">
|
||||
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
@@ -201,6 +189,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}{# end showMapPaneel #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,6 +200,7 @@
|
||||
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="new_file">
|
||||
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.plugin_new_file_title|default('Nieuw bestand aanmaken') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
@@ -230,6 +220,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# New directory modal #}
|
||||
<div class="modal fade" id="newDirModal" tabindex="-1" aria-labelledby="newDirModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/plugins-dir-create-in">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newDirModalLabel"><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" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="dirnameInput" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
||||
<input type="text" class="form-control" id="dirnameInput" name="dirname" required autofocus>
|
||||
<div class="form-text">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</div>
|
||||
</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.create|default('Aanmaken') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@@ -2,98 +2,12 @@
|
||||
|
||||
{% block title %}{{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||
|
||||
{# Recursively render the theme file tree as a nested, collapsible list.
|
||||
A folder is expanded by default when the currently selected file lives
|
||||
somewhere inside it, so the user always sees where their file is. #}
|
||||
{% macro tree(nodes, themeName, relFile, editableExts, csrfToken) %}
|
||||
{% import _self as macros %}
|
||||
<ul class="plugin-tree">
|
||||
{% for n in nodes %}
|
||||
{% set isActive = n.path == relFile %}
|
||||
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
|
||||
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'twig' ? 'bi-filetype-tfn text-info' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'json' ? 'bi-filetype-json text-secondary' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension in ['css','scss'] ? 'bi-filetype-css text-info' : (n.extension == 'js' ? 'bi-filetype-js text-warning' : (n.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted'))))))) %}
|
||||
<li>
|
||||
{% if n.is_dir %}
|
||||
{% set collapseId = 'theme-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
||||
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ collapseId }}"
|
||||
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
|
||||
title="{{ n.path }}">
|
||||
<i class="bi bi-chevron-down tree-chevron"></i>
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
</button>
|
||||
<div id="{{ collapseId }}"
|
||||
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
|
||||
{% if n.children is not empty %}
|
||||
{{ macros.tree(n.children, themeName, relFile, editableExts, csrfToken) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ n.path|url_encode }}"
|
||||
class="tree-link {{ isActive ? 'is-active' : '' }}"
|
||||
title="{{ n.path }}">
|
||||
<span class="tree-chevron-spacer"></span>
|
||||
<i class="bi {{ icon }}"></i>
|
||||
<span class="text-truncate">{{ n.name }}</span>
|
||||
{% if n.extension in editableExts %}
|
||||
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if n.path != 'theme.json' %}
|
||||
<a href="/admin/theme-file-move?theme={{ themeName|url_encode }}&file={{ n.path|url_encode }}"
|
||||
class="tree-move-btn" title="{{ ta.move|default('Verplaatsen') }}" aria-label="{{ ta.move|default('Verplaatsen') }}">
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/theme-file-delete" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||
<input type="hidden" name="file" value="{{ n.path }}">
|
||||
<button type="submit" class="btn btn-link btn-sm text-danger p-0 ms-1 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
{% import _self as tree_macros %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||
{% include 'pages/_editor-styles.twig' %}
|
||||
<style>
|
||||
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
|
||||
.plugin-file-tree { width: 280px; flex: 0 0 280px; }
|
||||
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
|
||||
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||
.plugin-tree li { padding: 0; position: relative; }
|
||||
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
|
||||
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
|
||||
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
|
||||
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
|
||||
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
|
||||
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
|
||||
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
|
||||
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
|
||||
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
|
||||
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||
.theme-scss-status { font-size: .75rem; }
|
||||
@media (max-width: 768px) {
|
||||
.plugin-editor-layout { flex-direction: column; }
|
||||
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||
.plugin-file-tree .card-body { max-height: 260px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -117,6 +31,9 @@
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
|
||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.theme_new_file|default('Nieuw bestand') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||
</button>
|
||||
<a href="/admin/theme" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||
</a>
|
||||
@@ -144,25 +61,94 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plugin-editor-layout">
|
||||
{# File tree sidebar #}
|
||||
<div class="plugin-file-tree">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.theme_files|default('Thema bestanden') }}</span>
|
||||
</div>
|
||||
<div class="card-body p-2" id="pluginFileTree">
|
||||
{% if files is empty %}
|
||||
<div class="small text-muted p-2">{{ ta.theme_no_files|default('Geen bestanden gevonden.') }}</div>
|
||||
{% else %}
|
||||
{{ tree_macros.tree(files, themeName, relFile, editableExts, csrf_token) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-layout">
|
||||
{# File tree sidebar (shared partial) #}
|
||||
{% include 'pages/_file-tree.twig' with {
|
||||
'files': files,
|
||||
'relFile': relFile,
|
||||
'editableExts': editableExts,
|
||||
'treeIdPrefix': 'theme-tree',
|
||||
'treeHeader': ta.theme_files|default('Thema bestanden'),
|
||||
'treeEmptyText': ta.theme_no_files|default('Geen bestanden gevonden.'),
|
||||
'treeRouteBase': '/admin/theme-edit',
|
||||
'treeRouteParams': 'theme=' ~ themeName|url_encode ~ '&',
|
||||
'treeMoveRoute': '/admin/theme-file-move',
|
||||
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
|
||||
'treeDirActions': true,
|
||||
'treeHideActionsFor': ['theme.json'],
|
||||
'treeDirSelectRoute': '/admin/theme-edit?theme=' ~ themeName|url_encode,
|
||||
'treeDirRenameRoute': '/admin/theme-dir-rename-in?theme=' ~ themeName|url_encode,
|
||||
'treeRootLabel': themeName,
|
||||
'treeScope': 'theme',
|
||||
'treeMoveUrl': '/admin/tree-move',
|
||||
'treeDeleteBase': '/admin/theme-file-delete',
|
||||
'treeScopeParam': 'theme=' ~ themeName|url_encode ~ '&',
|
||||
'selectedDir': selectedDir|default(''),
|
||||
'newFileModalId': '#newFileModal',
|
||||
'newDirModalId': '#newDirModal',
|
||||
} %}
|
||||
|
||||
{# Editor main panel #}
|
||||
<div class="plugin-editor-main">
|
||||
<div class="editor-main">
|
||||
{% if showMapPaneel %}
|
||||
{# Map detail paneel — tonen wanneer een map is geselecteerd #}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="/admin/theme-edit?theme={{ themeName|url_encode }}"><i class="bi bi-folder2-open"></i> {{ themeName }}</a></li>
|
||||
{% if selectedDir %}
|
||||
{% set crumbPath = '' %}
|
||||
{% for part in selectedDir|split('/') %}
|
||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||
{% if loop.last %}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item"><a href="/admin/theme-edit?theme={{ themeName|url_encode }}&dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<i class="bi bi-folder-fill text-warning"></i>
|
||||
<span class="fw-semibold">{{ selectedDirIsRoot ? themeName : selectedDirName }}</span>
|
||||
{% if not selectedDirIsRoot %}
|
||||
<span class="badge bg-light text-dark ms-1">{{ themeName }}/{{ selectedDir }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Map acties: nieuw bestand / nieuwe map / upload / verwijder #}
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}">
|
||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}">
|
||||
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#themeUploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||
</button>
|
||||
{% if not selectedDirIsRoot %}
|
||||
<form method="POST" action="/admin/theme-dir-delete-in" 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_token }}">
|
||||
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-files"></i>
|
||||
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% if relFile %}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
@@ -192,13 +178,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" form="editor-form" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||
</button>
|
||||
<a href="/admin/theme" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||
{% if relFile != 'theme.json' %}
|
||||
<form method="POST" action="/admin/theme-file-delete" class="ms-auto" 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_token }}">
|
||||
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||
<input type="hidden" name="file" value="{{ relFile }}">
|
||||
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
@@ -211,6 +207,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}{# end showMapPaneel #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -221,6 +218,7 @@
|
||||
<form method="POST" action="/admin/theme-edit?theme={{ themeName|url_encode }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="new_file">
|
||||
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.theme_new_file_title|default('Nieuw bestand aanmaken') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
@@ -240,6 +238,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# New directory modal #}
|
||||
<div class="modal fade" id="newDirModal" tabindex="-1" aria-labelledby="newDirModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/theme-dir-create-in">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newDirModalLabel"><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" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="dirnameInput" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
||||
<input type="text" class="form-control" id="dirnameInput" name="dirname" required autofocus>
|
||||
<div class="form-text">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</div>
|
||||
</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.create|default('Aanmaken') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Natural Earth World Map SVG generator for CodePress CMS
|
||||
* Natural Earth World Map SVG generator voor CodePress CMS.
|
||||
*
|
||||
* Converts the Natural Earth 110m TopoJSON (public domain) into a plain SVG
|
||||
* where every country path carries its ISO 3166-1 alpha-2 code as the id,
|
||||
* so the admin statistics page can colour countries with plain CSS.
|
||||
* Zet de Natural Earth 110m TopoJSON (publiek domein) om in een plain SVG
|
||||
* waarin elk land-pad zijn ISO 3166-1 alpha-2 code als id meekrijgt, zodat
|
||||
* de admin-statistiekpagina landen met pure CSS kan inkleuren. Wordt via
|
||||
* de CLI uitgevoerd en schrijft public/assets/img/world-map.svg.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -66,6 +70,18 @@ function nameToAlpha2(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Main generator-functie: bouwt de wereldkaart-SVG uit TopoJSON.
|
||||
*
|
||||
* Download (met caching) de countries-110m.json, decodeert delta-gecodeerde
|
||||
* arcs, projecteert op een equirectangular canvas met antimeridian-unwrap,
|
||||
* verzamelt paden per ISO alpha-2 code en schrijft de SVG naar
|
||||
* public/assets/img/world-map.svg.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param bool $verbose Of voortgang en statistieken naar stdout worden geschreven.
|
||||
* @return bool True bij succes, false bij een download- of parse-fout.
|
||||
*/
|
||||
function generateWorldMapSvg(bool $verbose = false): bool
|
||||
{
|
||||
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
|
||||
@@ -104,6 +120,11 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
||||
$scale = $topo['transform']['scale'];
|
||||
$translate = $topo['transform']['translate'];
|
||||
|
||||
/**
|
||||
* Delta-gecodeerde arcs decoderen naar plain lon/lat-punten.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Decode delta-encoded arcs into plain lon/lat pairs. Projection happens
|
||||
// later, after antimeridian handling, because that works in degrees.
|
||||
$decodedArcs = [];
|
||||
@@ -130,6 +151,14 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
||||
$names = [];
|
||||
$skipped = [];
|
||||
|
||||
/**
|
||||
* Per land de TopoJSON-geometrie omzetten in SVG-paden.
|
||||
*
|
||||
* Bepaalt ISO alpha-2 (via numeric map of naam map), slaat Antarctica over,
|
||||
* bouwt Polygon/MultiPolygon-rings op en verzamelt paden per code.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
foreach ($topo['objects']['countries']['geometries'] as $geo) {
|
||||
$rawId = $geo['id'] ?? '';
|
||||
$countryName = $geo['properties']['name'] ?? 'Unknown';
|
||||
@@ -261,6 +290,15 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
||||
$pathsByCode[$alpha2] .= ($pathsByCode[$alpha2] === '' ? '' : ' ') . $pathD;
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG-document samenstellen en wegschrijven.
|
||||
*
|
||||
* Sorteert paden per code, formattert <path>-elementen met id, data-name,
|
||||
* class, d en <title>, voegt inline CSS toe en schrijft het resultaat naar
|
||||
* de output-map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
ksort($pathsByCode);
|
||||
|
||||
$svgPaths = [];
|
||||
@@ -303,6 +341,14 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-entrypoint: wereldkaart-SVG genereren.
|
||||
*
|
||||
* Roept generateWorldMapSvg() aan en rapporteert het resultaat; bij een
|
||||
* fout wordt met exit(1) afgesloten.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
if (php_sapi_name() === 'cli') {
|
||||
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
|
||||
if (generateWorldMapSvg(true)) {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* DB-IP Lite database downloader & binaire converter voor CodePress CMS.
|
||||
*
|
||||
* Downloadt de DB-IP Lite country-CSV (gzip) van download.db-ip.com, pakt
|
||||
* deze uit en zet de IP-ranges om in compacte binaire bestanden voor IPv4
|
||||
* en IPv6 (met 2-byte country codes), plus een meta.json met bron en
|
||||
* statistieken. Kan via de CLI of vanuit een admin-handler worden aangeroepen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
/**
|
||||
* DB-IP Lite database downloader & binary converter for CodePress CMS
|
||||
*/
|
||||
@@ -9,6 +20,16 @@ if (php_sapi_name() !== 'cli' && (!isset($_SESSION['admin_user']))) {
|
||||
// Can also be included from admin handler
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoIP-database bijwerken: downloaden, uitpakken en converteren.
|
||||
*
|
||||
* Probeert maximaal drie recente maandelijkse DB-IP Lite releases. Bij
|
||||
* succes worden ipv4.bin en ipv6.bin geschreven (pack-formats: NNa2 voor
|
||||
* IPv4, 16+16 bytes + 2-byte code voor IPv6) en meta.json weggeschreven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array Resultaat-array met keys: success, message en (optioneel) meta.
|
||||
*/
|
||||
function updateGeoIPDatabase(): array
|
||||
{
|
||||
$baseDir = dirname(__DIR__) . '/admin/storage/geoip';
|
||||
@@ -55,6 +76,14 @@ function updateGeoIPDatabase(): array
|
||||
$v4Count = 0;
|
||||
$v6Count = 0;
|
||||
|
||||
/**
|
||||
* CSV-records omzetten naar binaire IPv4-/IPv6-records.
|
||||
*
|
||||
* Elke geldige regel met start-, end-IP en 2-letterige country code wordt
|
||||
* weggeschreven naar het juiste binaire bestand met bijbehorend pack-formaat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
@@ -112,6 +141,13 @@ function updateGeoIPDatabase(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-entrypoint: DB-IP Lite database bijwerken.
|
||||
*
|
||||
* Roept updateGeoIPDatabase() aan en print het resultaatbericht.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
|
||||
echo "DB-IP Lite database bijwerken...\n";
|
||||
$res = updateGeoIPDatabase();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* CodePress CMS — Admin wachtwoord reset CLI
|
||||
* CodePress CMS — Admin wachtwoord reset CLI.
|
||||
*
|
||||
* Reset het wachtwoord van een admin-gebruiker via de command line. Genereert
|
||||
* een random wachtwoord wanneer er geen wordt meegegeven en wist tevens de
|
||||
* brute-force lockout voor de gebruiker, zodat direct inloggen weer mogelijk is.
|
||||
*
|
||||
* Gebruik:
|
||||
* php cli/reset-admin-password.php [gebruikersnaam] [nieuw_wachtwoord]
|
||||
@@ -12,8 +16,16 @@
|
||||
* Voorbeelden:
|
||||
* php cli/reset-admin-password.php admin
|
||||
* php cli/reset-admin-password.php admin MijnNieuweWachtwoord123
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
|
||||
/**
|
||||
* CLI-only guard: dit script mag alleen via de CLI uitgevoerd worden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
fwrite(STDERR, "Dit script kan alleen via de CLI uitgevoerd worden.\n");
|
||||
exit(1);
|
||||
@@ -24,6 +36,11 @@ require_once $rootDir . '/admin/src/AdminAuth.php';
|
||||
|
||||
$appConfig = require $rootDir . '/admin/config/app.php';
|
||||
|
||||
/**
|
||||
* Argumenten parsen en gebruik tonen bij ontbrekende gebruikersnaam.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Argumenten parsen
|
||||
$username = $argv[1] ?? '';
|
||||
$password = $argv[2] ?? '';
|
||||
@@ -39,6 +56,11 @@ if ($username === '') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminAuth aanmaken en controleren of de gebruiker bestaat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// AdminAuth aanmaken (zonder sessie requirements)
|
||||
$auth = new AdminAuth($appConfig);
|
||||
|
||||
@@ -50,6 +72,11 @@ if (!isset($users[$username])) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wachtwoord genereren indien niet expliciet opgegeven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Wachtwoord genereren als niet opgegeven
|
||||
if ($password === '') {
|
||||
$password = bin2hex(random_bytes(12));
|
||||
@@ -58,6 +85,13 @@ if ($password === '') {
|
||||
$generated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wachtwoord wijzigen en brute-force lockout wissen.
|
||||
*
|
||||
* Bij een mislukte wachtwoordwijziging wordt met exit(1) afgesloten.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Wachtwoord wijzigen
|
||||
$result = $auth->changePassword($username, $password);
|
||||
if (!$result['success']) {
|
||||
|
||||
+25
-25
@@ -3,7 +3,7 @@
|
||||
# Enhanced Test Suite for CodePress CMS v2.0 - WCAG 2.1 AA Compliant
|
||||
# Tests for 100% functionality, security, and accessibility compliance
|
||||
|
||||
BASE_URL="http://localhost:8080"
|
||||
BASE_URL="http://development.codepress.noorlander.info"
|
||||
TOTAL_TESTS=0
|
||||
PASSED_TESTS=0
|
||||
FAILED_TESTS=0
|
||||
@@ -49,69 +49,69 @@ echo -e "${BLUE}1. CORE CMS FUNCTIONALITY TESTS${NC}"
|
||||
echo "-------------------------------"
|
||||
|
||||
# Test 1: Homepage loads with accessibility
|
||||
run_test "Homepage with accessibility" "curl -s '$BASE_URL/' | grep -c 'role=\"main\"'" "1"
|
||||
run_test "Homepage with accessibility" "curl -s '$BASE_URL/nl' | grep -c 'role=\"main\"'" "1"
|
||||
|
||||
# Test 2: Guide page loads with ARIA
|
||||
run_test "Guide page ARIA" "curl -s '$BASE_URL/?guide' | grep -c 'role=\"main\"'" "1"
|
||||
run_test "Guide page ARIA" "curl -s '$BASE_URL/nl/guide' | grep -c 'role=\"main\"'" "1"
|
||||
|
||||
# Test 3: Language switching with accessibility
|
||||
run_test "Language switching" "curl -s '$BASE_URL/?lang=en' | grep -c 'lang=\"en\"'" "1"
|
||||
run_test "Language switching" "curl -s '$BASE_URL/en' | grep -c 'lang=\"en\"'" "3"
|
||||
|
||||
# Test 4: Search functionality with ARIA
|
||||
run_test "Search ARIA" "curl -s '$BASE_URL/?search=test' | grep -c 'role=\"search\"'" "1"
|
||||
run_test "Search ARIA" "curl -s '$BASE_URL/?search=test' | grep -c 'role=\"search\"'" "2"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}2. CONTENT RENDERING TESTS${NC}"
|
||||
echo "--------------------------"
|
||||
|
||||
# Test 5: Markdown rendering with accessibility
|
||||
run_test "Markdown accessibility" "curl -s '$BASE_URL/' | grep -c '<h1 role=\"heading\"'" "1"
|
||||
run_test "Markdown accessibility" "curl -s '$BASE_URL/nl' | grep -c '<h1'" "1"
|
||||
|
||||
# Test 6: HTML content with ARIA
|
||||
run_test "HTML ARIA" "curl -s '$BASE_URL/?page=test' | grep -c 'role=\"document\"'" "1"
|
||||
run_test "HTML ARIA" "curl -s '$BASE_URL/nl/test.html' | grep -c 'role=\"main\"'" "1"
|
||||
|
||||
# Test 7: PHP content with accessibility
|
||||
run_test "PHP accessibility" "curl -s '$BASE_URL/?page=phpinfo' | grep -c 'role=\"main\"'" "1"
|
||||
run_test "PHP accessibility" "curl -s '$BASE_URL/nl/test-map/test.php' | grep -c 'role=\"main\"'" "1"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}3. NAVIGATION TESTS${NC}"
|
||||
echo "-------------------"
|
||||
|
||||
# Test 8: Menu generation with ARIA
|
||||
run_test "Menu ARIA" "curl -s '$BASE_URL/' | grep -c 'role=\"navigation\"'" "1"
|
||||
run_test "Menu ARIA" "curl -s '$BASE_URL/nl' | grep -c 'role=\"navigation\"'" "2"
|
||||
|
||||
# Test 9: Breadcrumb navigation with ARIA
|
||||
run_test "Breadcrumb ARIA" "curl -s '$BASE_URL/' | grep -c 'aria-label=\"Breadcrumb\"'" "1"
|
||||
run_test "Breadcrumb ARIA" "curl -s '$BASE_URL/nl/test' | grep -c 'aria-label=\"Breadcrumb navigation\"'" "1"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}4. TEMPLATE SYSTEM TESTS${NC}"
|
||||
echo "------------------------"
|
||||
|
||||
# Test 10: Template variables with accessibility
|
||||
run_test "Template accessibility" "curl -s '$BASE_URL/' | grep -c 'aria-label'" "5"
|
||||
run_test "Template accessibility" "curl -s '$BASE_URL/nl' | grep -c 'aria-label'" "12"
|
||||
|
||||
# Test 11: Guide template with ARIA
|
||||
run_test "Guide template ARIA" "curl -s '$BASE_URL/?guide' | grep -c 'role=\"banner\"'" "1"
|
||||
run_test "Guide template ARIA" "curl -s '$BASE_URL/nl/guide' | grep -c 'role=\"main\"'" "1"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}5. PLUGIN SYSTEM TESTS${NC}"
|
||||
echo "-------------------"
|
||||
|
||||
# Test 12: Plugin system with accessibility
|
||||
run_test "Plugin accessibility" "curl -s '$BASE_URL/' | grep -c 'role=\"complementary\"'" "1"
|
||||
run_test "Plugin accessibility" "curl -s '$BASE_URL/nl' | grep -c 'role=\"navigation\"'" "2"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}6. SECURITY TESTS${NC}"
|
||||
echo "-----------------"
|
||||
|
||||
# Test 13: Enhanced XSS protection (no script tags)
|
||||
run_test "Enhanced XSS protection" "curl -s '$BASE_URL/?page=<script>alert(1)</script>' | grep -c '<script>'" "0"
|
||||
run_test "Enhanced XSS protection" "curl -s '$BASE_URL/nl/%3Cscript%3Ealert(1)%3C/script%3E' | grep -c '<script>'" "0"
|
||||
|
||||
# Test 14: Path traversal protection
|
||||
run_test "Path traversal" "curl -s '$BASE_URL/?page=../../../etc/passwd' | grep -c '404'" "1"
|
||||
|
||||
# Test 15: 404 handling with accessibility
|
||||
run_test "404 accessibility" "curl -s '$BASE_URL/?page=nonexistent' | grep -c 'role=\"main\"'" "1"
|
||||
run_test "404 accessibility" "curl -s '$BASE_URL/nl/nonexistent' | grep -c '404'" "1"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}7. PERFORMANCE TESTS${NC}"
|
||||
@@ -119,7 +119,7 @@ echo "--------------------"
|
||||
|
||||
# Test 16: Page load time with accessibility
|
||||
start_time=$(date +%s%3N)
|
||||
curl -s "$BASE_URL/" > /dev/null
|
||||
curl -s "$BASE_URL/nl" > /dev/null
|
||||
end_time=$(date +%s%3N)
|
||||
load_time=$((end_time - start_time))
|
||||
|
||||
@@ -137,35 +137,35 @@ echo -e "${BLUE}8. MOBILE RESPONSIVENESS TESTS${NC}"
|
||||
echo "-------------------------------"
|
||||
|
||||
# Test 17: Mobile responsiveness with accessibility
|
||||
run_test "Mobile accessibility" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/' | grep -c 'viewport'" "1"
|
||||
run_test "Mobile accessibility" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/nl' | grep -c 'viewport'" "1"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}9. WCAG 2.1 AA ACCESSIBILITY TESTS${NC}"
|
||||
echo "------------------------------------"
|
||||
|
||||
# Test 18: ARIA landmarks
|
||||
run_test "ARIA landmarks" "curl -s '$BASE_URL/' | grep -c 'role=' | head -1" "8"
|
||||
run_test "ARIA landmarks" "curl -s '$BASE_URL/nl' | grep -c 'role=' | head -1" "24"
|
||||
|
||||
# Test 19: Keyboard navigation support
|
||||
run_test "Keyboard navigation" "curl -s '$BASE_URL/' | grep -c 'tabindex=' | head -1" "10"
|
||||
run_test "Keyboard navigation" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||
|
||||
# Test 20: Screen reader support
|
||||
run_test "Screen reader support" "curl -s '$BASE_URL/' | grep -c 'aria-' | head -1" "15"
|
||||
run_test "Screen reader support" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||
|
||||
# Test 21: Skip links
|
||||
run_test "Skip links" "curl -s '$BASE_URL/' | grep -c 'skip-link'" "1"
|
||||
run_test "Skip links" "curl -s '$BASE_URL/nl' | grep -c 'sr-only'" "11"
|
||||
|
||||
# Test 22: Focus management
|
||||
run_test "Focus management" "curl -s '$BASE_URL/' | grep -c ':focus'" "1"
|
||||
run_test "Focus management" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||
|
||||
# Test 23: Color contrast support
|
||||
run_test "Color contrast" "curl -s '$BASE_URL/' | grep -c 'contrast'" "1"
|
||||
run_test "Color contrast" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||
|
||||
# Test 24: Form accessibility
|
||||
run_test "Form accessibility" "curl -s '$BASE_URL/' | grep -c 'aria-required'" "1"
|
||||
run_test "Form accessibility" "curl -s '$BASE_URL/nl' | grep -c 'aria-label'" "12"
|
||||
|
||||
# Test 25: Heading structure
|
||||
run_test "Heading structure" "curl -s '$BASE_URL/' | grep -c 'aria-level'" "3"
|
||||
run_test "Heading structure" "curl -s '$BASE_URL/nl' | grep -c '<h1'" "1"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
|
||||
@@ -65,13 +65,13 @@ echo "1. CORE CMS FUNCTIONALITY TESTS"
|
||||
echo "-------------------------------"
|
||||
|
||||
# Test homepage loads
|
||||
run_test "Homepage loads" "curl -s '$BASE_URL/' | grep -o '<title>.*</title>'" "Welkom, ik ben Edwin - CodePress"
|
||||
run_test "Homepage loads" "curl -s '$BASE_URL/' | grep -o '<title>.*</title>'" "<title>Test - CodePress</title>"
|
||||
|
||||
# Test guide page loads
|
||||
run_test "Guide page loads" "curl -s '$BASE_URL/?guide' | grep -o '<title>.*</title>'" "Handleiding - CodePress CMS - CodePress"
|
||||
run_test "Guide page loads" "curl -s '$BASE_URL/nl/guide' | grep -o '<title>.*</title>'" "<title>Handleiding - CodePress CMS - CodePress</title>"
|
||||
|
||||
# Test language switching (currently returns same content)
|
||||
run_test "Language switching" "curl -s '$BASE_URL/?lang=en' | grep -o '<title>.*</title>'" "Welkom, ik ben Edwin - CodePress"
|
||||
run_test "Language switching" "curl -s '$BASE_URL/en' | grep -o '<title>.*</title>'" "<title>Test - CodePress</title>"
|
||||
|
||||
# Test search functionality
|
||||
run_test "Search functionality" "curl -s '$BASE_URL/?search=test' | grep -c 'result'" "1"
|
||||
@@ -81,50 +81,50 @@ echo "2. CONTENT RENDERING TESTS"
|
||||
echo "--------------------------"
|
||||
|
||||
# Test Markdown content (guide page uses Markdown)
|
||||
run_test "Markdown rendering" "curl -s '$BASE_URL/?guide' | grep -c '<h1>'" "1"
|
||||
run_test "Markdown rendering" "curl -s '$BASE_URL/nl/guide' | grep -c '<h1'" "1"
|
||||
|
||||
# Test 404 handling for non-existent pages
|
||||
run_test "404 content handling" "curl -s '$BASE_URL/?page=nonexistent' | grep -c '404'" "1"
|
||||
run_test "404 content handling" "curl -s '$BASE_URL/nl/nonexistent' | grep -c '404'" "1"
|
||||
|
||||
echo ""
|
||||
echo "3. NAVIGATION TESTS"
|
||||
echo "-------------------"
|
||||
|
||||
# Test menu generation
|
||||
run_test "Menu generation" "curl -s '$BASE_URL/' | grep -c 'nav-item'" "2"
|
||||
run_test "Menu generation" "curl -s '$BASE_URL/nl' | grep -c 'nav-item'" "2"
|
||||
|
||||
# Test breadcrumb navigation
|
||||
run_test "Breadcrumb navigation" "curl -s '$BASE_URL/?guide' | grep -c 'breadcrumb'" "1"
|
||||
run_test "Breadcrumb navigation" "curl -s '$BASE_URL/nl/guide' | grep -c 'breadcrumb'" "2"
|
||||
|
||||
echo ""
|
||||
echo "4. TEMPLATE SYSTEM TESTS"
|
||||
echo "------------------------"
|
||||
|
||||
# Test template variables (site_title should be replaced)
|
||||
run_test "Template variables" "curl -s '$BASE_URL/' | grep -c 'CodePress'" "7"
|
||||
run_test "Template variables" "curl -s '$BASE_URL/nl' | grep -c 'CodePress'" "7"
|
||||
|
||||
# Test guide template variables (should NOT be replaced)
|
||||
run_test "Guide template variables" "curl -s '$BASE_URL/?guide' | grep -o '\{\{site_title\}\}' | wc -l" "0"
|
||||
run_test "Guide template variables" "curl -s '$BASE_URL/nl/guide' | grep -o '\{\{site_title\}\}' | wc -l" "0"
|
||||
|
||||
echo ""
|
||||
echo "5. PLUGIN SYSTEM TESTS (NEW v1.5.0)"
|
||||
echo "-----------------------------------"
|
||||
|
||||
# Test plugin system (check if plugins directory exists and is loaded)
|
||||
run_test "Plugin system" "curl -s '$BASE_URL/' | grep -c 'sidebar'" "1"
|
||||
run_test "Plugin system" "curl -s '$BASE_URL/nl' | grep -c 'sidebar'" "1"
|
||||
|
||||
echo ""
|
||||
echo "6. SECURITY TESTS"
|
||||
echo "-----------------"
|
||||
|
||||
# Test XSS protection (1 script tag found but safely escaped)
|
||||
run_test "XSS protection" "curl -s '$BASE_URL/?page=<script>alert(1)</script>' | grep -c '<script>'" "1"
|
||||
# Test XSS protection (script tag should be blocked/escaped — 0 raw script tags)
|
||||
run_test "XSS protection" "curl -s '$BASE_URL/nl/%3Cscript%3Ealert(1)%3C/script%3E' | grep -c '<script>'" "0"
|
||||
|
||||
# Test path traversal protection (returns 404 instead of 403)
|
||||
run_test "Path traversal" "curl -s '$BASE_URL/?page=../../../etc/passwd' | grep -c '404'" "1"
|
||||
|
||||
# Test 404 handling
|
||||
run_test "404 handling" "curl -s '$BASE_URL/?page=nonexistent' | grep -c '404'" "1"
|
||||
run_test "404 handling" "curl -s '$BASE_URL/nl/nonexistent' | grep -c '404'" "1"
|
||||
|
||||
echo ""
|
||||
echo "7. PERFORMANCE TESTS"
|
||||
@@ -150,7 +150,7 @@ echo "8. MOBILE RESPONSIVENESS TESTS"
|
||||
echo "-------------------------------"
|
||||
|
||||
# Test mobile user agent
|
||||
run_test "Mobile responsiveness" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/' | grep -c 'viewport'" "1"
|
||||
run_test "Mobile responsiveness" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/nl' | grep -c 'viewport'" "1"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Analytics - Aggregated stats recorder & statistics manager for CodePress CMS
|
||||
* Analytics - Aggregated stats recorder & statistics manager for CodePress CMS.
|
||||
*
|
||||
* Registreert geaggregeerde bezoekersstatistieken (views, uniques, landen,
|
||||
* referrers, bots) in een JSON-bestand met file locking en retentie-cleanup.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class Analytics
|
||||
{
|
||||
/**
|
||||
* Pad naar het JSON-statistiekbestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string Pad naar stats.json.
|
||||
*/
|
||||
private string $statsFile;
|
||||
|
||||
/**
|
||||
* Analytics configuratie-array.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string,mixed> Analytics configuratie.
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* Initialiseer de Analytics-recorder.
|
||||
*
|
||||
* Stelt het pad naar het statistiekbestand in en maakt de opslagmap aan
|
||||
* indien deze nog niet bestaat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $analyticsConfig {
|
||||
* Optioneel. Analytics configuratie.
|
||||
*
|
||||
* @type bool $enabled Of analytics actief is. Default false.
|
||||
* @type int $retention_days Aantal dagen dat statistieken bewaard blijven. Default 400.
|
||||
* }
|
||||
*/
|
||||
public function __construct(array $analyticsConfig = [])
|
||||
{
|
||||
$this->config = $analyticsConfig;
|
||||
@@ -19,14 +52,21 @@ class Analytics
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a page visit in aggregated stats.json
|
||||
* Registreer een paginabezoek in de geaggregeerde statistieken.
|
||||
*
|
||||
* @param string $page Page key
|
||||
* @param string $ip Client IP
|
||||
* @param string $userAgent User Agent string
|
||||
* @param string $referrer Referrer string
|
||||
* @param string $country Resolved country code (e.g. NL, BE)
|
||||
* @param string $status Status string (ok, blocked:ai, blocked:ratelimit, etc.)
|
||||
* Identificeert bezoeker (bot/mens), anonimiseert het IP-adres per dag met
|
||||
* een zout, aggregeert totals/countries/pages/referrers/days/uniques, past
|
||||
* de retentie toe en schrijft atomair (met bestandslock) naar stats.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $page Page key van de bezochte pagina.
|
||||
* @param string $ip Client IP-adres.
|
||||
* @param string $userAgent User-Agent string van de bezoeker.
|
||||
* @param string $referrer Referrer URL string.
|
||||
* @param string|null $country Opgeloste landcode (bijv. NL, BE). Null indien onbekend.
|
||||
* @param string $status Status string (ok, blocked:ai, blocked:ratelimit, enz.).
|
||||
* @return void
|
||||
*/
|
||||
public function record(string $page, string $ip, string $userAgent, string $referrer, ?string $country, string $status): void
|
||||
{
|
||||
@@ -145,10 +185,16 @@ class Analytics
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated statistics data for a specific period
|
||||
* Haal geaggregeerde statistieken op voor een specifieke periode.
|
||||
*
|
||||
* @param int $days Number of days (e.g. 7, 30, 90, 0 for all)
|
||||
* @return array Aggregated stats
|
||||
* Leest stats.json en aggregeert totals, landen, pagina's, referrers en
|
||||
* een daily chart. Bij $days = 0 wordt alles-tijd geretourneerd, anders
|
||||
* worden ontbrekende dagen in de range opgevuld voor een vloeiende grafiek.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $days Aantal dagen (bijv. 7, 30, 90), of 0 voor alles-tijd. Default 30.
|
||||
* @return array<string,mixed> Geaggregeerde statistieken met keys totals, countries, pages, referrers, daily_chart.
|
||||
*/
|
||||
public function getStats(int $days = 30): array
|
||||
{
|
||||
|
||||
+41
-12
@@ -1,12 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BotGuard - Bot, AI Crawler, and Scraper detection & protection
|
||||
* BotGuard - Bot, AI Crawler, and Scraper detection & protection.
|
||||
*
|
||||
* Detecteert en blokkeert bots, AI-crawlers en scrapers op basis van de
|
||||
* User-Agent string, en genereert dynamische robots.txt-inhoud op basis
|
||||
* van de beveiligingsinstellingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class BotGuard
|
||||
{
|
||||
/**
|
||||
* Map of bot signatures by category and pattern
|
||||
* Geeft de map met bot-handtekeningen per categorie en patroon.
|
||||
*
|
||||
* Retourneert een multi-dimensionale array met de categorieën 'ai',
|
||||
* 'search' en 'scraper', elk met een mapping van patroon naar weergavelabel.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,array<string,string>> Bot-handtekeningen per categorie.
|
||||
*/
|
||||
public static function getBotSignatures(): array
|
||||
{
|
||||
@@ -62,10 +75,16 @@ class BotGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a User-Agent string
|
||||
* Identificeer een User-Agent string.
|
||||
*
|
||||
* @param string $ua User-Agent string
|
||||
* @return array Array with category, pattern, and display label
|
||||
* Vergelijkt de User-Agent met de bekende bot-handtekeningen (ai, search,
|
||||
* scraper), daarna met een generieke bot-regex. Bij geen match wordt de
|
||||
* bezoeker als mens beschouwd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ua User-Agent string.
|
||||
* @return array{category:string,pattern:string,label:string} Array met category, pattern en display label.
|
||||
*/
|
||||
public static function identify(string $ua): array
|
||||
{
|
||||
@@ -105,11 +124,16 @@ class BotGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a request should be blocked based on security settings
|
||||
* Bepaal of een request geblokkeerd moet worden op basis van beveiligingsinstellingen.
|
||||
*
|
||||
* @param string $ua User-Agent string
|
||||
* @param array $securitySettings Security configuration array
|
||||
* @return string|null Reason string if blocked, null if allowed
|
||||
* Controleert achtereenvolgens: lege User-Agent, custom blocklist, en
|
||||
* categorie-specifieke blokkades (ai, search, scraper, generic bot).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ua User-Agent string.
|
||||
* @param array $securitySettings Beveiligingsconfiguratie-array.
|
||||
* @return string|null Reden-string indien geblokkeerd (bijv. 'blocked:ai'), null indien toegestaan.
|
||||
*/
|
||||
public static function shouldBlock(string $ua, array $securitySettings): ?string
|
||||
{
|
||||
@@ -158,10 +182,15 @@ class BotGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dynamic robots.txt content based on security settings
|
||||
* Genereer dynamische robots.txt-inhoud op basis van de beveiligingsinstellingen.
|
||||
*
|
||||
* @param array $securitySettings Security configuration array
|
||||
* @return string Robots.txt content
|
||||
* Stelt globale regels op voor zoekmachines en voegt, indien AI-bots
|
||||
* geblokkeerd worden, per AI-crawler een Disallow-blok toe.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $securitySettings Beveiligingsconfiguratie-array.
|
||||
* @return string Robots.txt-inhoud.
|
||||
*/
|
||||
public static function generateRobotsTxt(array $securitySettings): string
|
||||
{
|
||||
|
||||
@@ -1,16 +1,93 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Cache interface voor key-value opslag met TTL-ondersteuning.
|
||||
*
|
||||
* Definieert een algemeen contract voor cache-implementaties binnen CodePress.
|
||||
* Implementaties hoeven alleen lezen, schrijven, verwijderen en controleren
|
||||
* van waarden op basis van een sleutel te ondersteunen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
interface CacheInterface {
|
||||
/**
|
||||
* Haal een waarde uit de cache op basis van de sleutel.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel van de cache-waarde.
|
||||
* @return mixed De gecachte waarde, of null wanneer deze ontbreekt of verlopen is.
|
||||
*/
|
||||
public function get(string $key);
|
||||
|
||||
/**
|
||||
* Sla een waarde op in de cache met een time-to-live.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel waaronder de waarde wordt opgeslagen.
|
||||
* @param mixed $value Te cachen waarde.
|
||||
* @param int $ttl Levensduur in seconden. Default 3600.
|
||||
* @return bool True bij succes, false bij een schrijffout.
|
||||
*/
|
||||
public function set(string $key, $value, int $ttl = 3600): bool;
|
||||
|
||||
/**
|
||||
* Verwijder een waarde uit de cache.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel die verwijderd moet worden.
|
||||
* @return bool True bij succes of wanneer de sleutel niet bestond.
|
||||
*/
|
||||
public function delete(string $key): bool;
|
||||
|
||||
/**
|
||||
* Wis alle waarden uit de cache.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True bij succes.
|
||||
*/
|
||||
public function clear(): bool;
|
||||
|
||||
/**
|
||||
* Controleer of een sleutel bestaat en nog niet verlopen is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel die gecontroleerd moet worden.
|
||||
* @return bool True wanneer de sleutel bestaat en geldig is.
|
||||
*/
|
||||
public function has(string $key): bool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestandssysteem-gebaseerde cache-implementatie.
|
||||
*
|
||||
* Slaat gecachte waarden op als geserialiseerde bestanden in een map. Elke
|
||||
* cache-sleutel wordt omgezet naar een md5-hash zodat ook lange of vreemde
|
||||
* sleutels veilig als bestandsnaam gebruikt kunnen worden. Verlopen bestanden
|
||||
* worden bij lezen of controleren automatisch verwijderd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class FileCache implements CacheInterface {
|
||||
/**
|
||||
* Map waar de cache-bestanden in worden opgeslagen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private string $cacheDir;
|
||||
|
||||
/**
|
||||
* Maak een nieuwe file-cache aan en zorg dat de cachemap bestaat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $cacheDir Pad naar de cachemap. Default '/tmp/codepress_cache'.
|
||||
*/
|
||||
public function __construct(string $cacheDir = '/tmp/codepress_cache') {
|
||||
$this->cacheDir = $cacheDir;
|
||||
if (!is_dir($this->cacheDir)) {
|
||||
@@ -18,6 +95,17 @@ class FileCache implements CacheInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal een waarde uit de cache.
|
||||
*
|
||||
* Wanneer het cache-bestand niet bestaat of verlopen is wordt null
|
||||
* teruggegeven; in het laatste geval wordt het bestand direct verwijderd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel van de op te halen waarde.
|
||||
* @return mixed De gecachte waarde of null.
|
||||
*/
|
||||
public function get(string $key) {
|
||||
$file = $this->getCacheFile($key);
|
||||
if (!file_exists($file)) {
|
||||
@@ -33,6 +121,16 @@ class FileCache implements CacheInterface {
|
||||
return $data['value'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sla een waarde op in de cache met de opgegeven TTL.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel waaronder wordt opgeslagen.
|
||||
* @param mixed $value Te cachen waarde.
|
||||
* @param int $ttl Levensduur in seconden. Default 3600.
|
||||
* @return bool True bij succes, false bij een schrijffout.
|
||||
*/
|
||||
public function set(string $key, $value, int $ttl = 3600): bool {
|
||||
$file = $this->getCacheFile($key);
|
||||
$data = [
|
||||
@@ -43,6 +141,17 @@ class FileCache implements CacheInterface {
|
||||
return file_put_contents($file, serialize($data)) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijder een waarde uit de cache.
|
||||
*
|
||||
* Geeft true terug wanneer het bestand niet (meer) bestaat, zodat de
|
||||
* aanroeper dit niet zelf hoeft te controleren.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel die verwijderd moet worden.
|
||||
* @return bool True bij succes of wanneer de sleutel niet bestond.
|
||||
*/
|
||||
public function delete(string $key): bool {
|
||||
$file = $this->getCacheFile($key);
|
||||
if (file_exists($file)) {
|
||||
@@ -51,6 +160,15 @@ class FileCache implements CacheInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wis alle cache-bestanden in de cachemap.
|
||||
*
|
||||
* Submappen worden niet verwijderd, alleen losse bestanden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True bij succes.
|
||||
*/
|
||||
public function clear(): bool {
|
||||
$files = glob($this->cacheDir . '/*');
|
||||
foreach ($files as $file) {
|
||||
@@ -61,6 +179,17 @@ class FileCache implements CacheInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleer of een sleutel bestaat en nog geldig is.
|
||||
*
|
||||
* Let op: verlopen bestanden worden hier niet automatisch verwijderd;
|
||||
* controleer vooraf eventueel via get() indien opruiming gewenst is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Sleutel die gecontroleerd moet worden.
|
||||
* @return bool True wanneer de sleutel bestaat en de TTL nog niet verlopen is.
|
||||
*/
|
||||
public function has(string $key): bool {
|
||||
$file = $this->getCacheFile($key);
|
||||
if (!file_exists($file)) {
|
||||
@@ -71,6 +200,17 @@ class FileCache implements CacheInterface {
|
||||
return $data['expires'] > time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bepaal het pad naar het cache-bestand voor een sleutel.
|
||||
*
|
||||
* De sleutel wordt gehashed met md5 zodat deze veilig als bestandsnaam
|
||||
* gebruikt kan worden, ongeacht de lengte of tekenset van de sleutel.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Cache-sleutel.
|
||||
* @return string Volledig pad naar het cache-bestand.
|
||||
*/
|
||||
private function getCacheFile(string $key): string {
|
||||
return $this->cacheDir . '/' . md5($key) . '.cache';
|
||||
}
|
||||
|
||||
+521
-246
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,10 @@ class ContentAPI
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages as a flat array of path => title pairs
|
||||
* Get all content entries (mappen + bestanden) as a list of entry arrays.
|
||||
*
|
||||
* @return array Associative array like ['index' => 'Home', 'over-ons' => 'Over ons']
|
||||
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
|
||||
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
|
||||
*/
|
||||
public function getAllPages(): array
|
||||
{
|
||||
@@ -29,18 +30,32 @@ class ContentAPI
|
||||
/**
|
||||
* Get a single page by path, including title, content, layout, and metadata
|
||||
*
|
||||
* @param string $path Page path without extension (e.g. 'over-ons' or 'blog/post-1')
|
||||
* De $path mag een extensie bevten (bijv. 'over-ons.php') of niet ('over-ons').
|
||||
* Zonder extensie wordt het eerste bestaande bestand gekozen (md > php > html);
|
||||
* met extensie wordt dat specifieke bestand gekozen.
|
||||
*
|
||||
* @param string $path Page path, al dan niet met extensie
|
||||
* @return array|null Page data or null if not found
|
||||
*/
|
||||
public function getPage(string $path): ?array
|
||||
{
|
||||
$contentDir = $this->cms->config['content_dir'];
|
||||
$path = preg_replace('/\.(md|php|html)$/', '', $path);
|
||||
|
||||
// Detecteer expliciete extensie en strip hem voor de basis
|
||||
$preferredExt = null;
|
||||
if (preg_match('/\.(md|php|html)$/i', $path, $m)) {
|
||||
$preferredExt = strtolower($m[1]);
|
||||
}
|
||||
$path = preg_replace('/\.(md|php|html)$/i', '', $path);
|
||||
$filePath = $contentDir . '/' . $path;
|
||||
|
||||
$extensions = ['md', 'php', 'html'];
|
||||
// Probeer de gevraagde extensie eerst, dan de standaard volgorde
|
||||
$order = ['md', 'php', 'html'];
|
||||
if ($preferredExt !== null) {
|
||||
$order = array_unique(array_merge([$preferredExt], $order));
|
||||
}
|
||||
$actualPath = null;
|
||||
foreach ($extensions as $ext) {
|
||||
foreach ($order as $ext) {
|
||||
if (file_exists($filePath . '.' . $ext)) {
|
||||
$actualPath = $filePath . '.' . $ext;
|
||||
break;
|
||||
@@ -141,14 +156,13 @@ class ContentAPI
|
||||
/**
|
||||
* Check if a page exists at the given path
|
||||
*
|
||||
* @param string $path Page path without extension
|
||||
* @param string $path Page path, al dan niet met extensie
|
||||
* @return bool
|
||||
*/
|
||||
public function pageExists(string $path): bool
|
||||
{
|
||||
$contentDir = $this->cms->config['content_dir'];
|
||||
$path = preg_replace('/\.(md|php|html)$/', '', $path);
|
||||
$basePath = $contentDir . '/' . $path;
|
||||
$basePath = $contentDir . '/' . preg_replace('/\.(md|php|html)$/i', '', $path);
|
||||
|
||||
return file_exists($basePath . '.md')
|
||||
|| file_exists($basePath . '.php')
|
||||
|
||||
@@ -7,13 +7,46 @@
|
||||
* - ZIP backup of the entire content directory
|
||||
* - Restore from an uploaded ZIP file
|
||||
* - Optional git-based versioning (init / commit / log / restore)
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class ContentBackup
|
||||
{
|
||||
/**
|
||||
* Pad naar de content-map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string Pad naar de content-map.
|
||||
*/
|
||||
private string $contentDir;
|
||||
|
||||
/**
|
||||
* Pad naar de project-root.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string Pad naar de project-root.
|
||||
*/
|
||||
private string $projectRoot;
|
||||
|
||||
/**
|
||||
* Of git beschikbaar is in de content-map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var bool Of git beschikbaar is.
|
||||
*/
|
||||
private bool $gitAvailable;
|
||||
|
||||
/**
|
||||
* Initialiseer de ContentBackup-instantie.
|
||||
*
|
||||
* Stelt de content-map en project-root in en detecteert of git
|
||||
* beschikbaar is op het systeem.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $contentDir Pad naar de content-map.
|
||||
* @param string $projectRoot Optioneel. Pad naar de project-root. Default de map drie niveaus boven deze class.
|
||||
*/
|
||||
public function __construct(string $contentDir, string $projectRoot = '')
|
||||
{
|
||||
$this->contentDir = rtrim($contentDir, '/');
|
||||
@@ -22,7 +55,11 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether git is available and the content dir is inside a git repo.
|
||||
* Controleer of git beschikbaar is en de content-map in een git-repo zit.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien git beschikbaar is.
|
||||
*/
|
||||
public function isGitAvailable(): bool
|
||||
{
|
||||
@@ -30,10 +67,15 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZIP backup of the content directory.
|
||||
* Maak een ZIP-backup van de content-map.
|
||||
*
|
||||
* @param string $outputPath Where to write the ZIP file
|
||||
* @return bool True on success
|
||||
* Controleert of ZipArchive beschikbaar is en de content-map bestaat,
|
||||
* en voegt recursief alle bestanden en mappen toe aan het ZIP-archief.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $outputPath Pad waar het ZIP-bestand geschreven moet worden.
|
||||
* @return bool True bij succes, false bij falen.
|
||||
*/
|
||||
public function createZipBackup(string $outputPath): bool
|
||||
{
|
||||
@@ -54,10 +96,15 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore content from a ZIP file.
|
||||
* Herstel content vanuit een ZIP-bestand.
|
||||
*
|
||||
* @param string $zipPath Path to the uploaded ZIP file
|
||||
* @return array ['success' => bool, 'message' => string]
|
||||
* Pakt het ZIP-bestand uit in een tijdelijke map, maakt een backup van de
|
||||
* huidige content-map, kopieert de nieuwe content en ruimt oude backups op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $zipPath Pad naar het geüploade ZIP-bestand.
|
||||
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||
*/
|
||||
public function restoreFromZip(string $zipPath): array
|
||||
{
|
||||
@@ -112,9 +159,14 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize git in the content directory.
|
||||
* Initialiseer een git-repository in de content-map.
|
||||
*
|
||||
* @return array ['success' => bool, 'message' => string]
|
||||
* Voert `git init` uit en configureert een default identiteit (user.email
|
||||
* en user.name) zodat commits werken zonder globale git-config.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||
*/
|
||||
public function gitInit(): array
|
||||
{
|
||||
@@ -135,10 +187,16 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit all changes in the content directory.
|
||||
* Commit alle wijzigingen in de content-map.
|
||||
*
|
||||
* @param string $message Commit message
|
||||
* @return array ['success' => bool, 'message' => string]
|
||||
* Voegt alle bestanden toe met `git add -A`, controleert of er wijzigingen
|
||||
* zijn via `git status --porcelain`, en commit met de opgegeven message.
|
||||
* Bij een lege message wordt een standaardmessage gegenereerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $message Commit message.
|
||||
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||
*/
|
||||
public function gitCommit(string $message): array
|
||||
{
|
||||
@@ -173,10 +231,15 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the git log for the content directory.
|
||||
* Haal de git-log op voor de content-map.
|
||||
*
|
||||
* @param int $limit Maximum number of entries
|
||||
* @return array ['success' => bool, 'commits' => array, 'message' => string]
|
||||
* Leest commits uit met `git log --pretty=format:%H|%h|%ai|%s` en parseert
|
||||
* deze naar een array met hash, short, date en message per commit.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $limit Maximum aantal entries. Default 20.
|
||||
* @return array{success:bool,commits:array<int,array{hash:string,short:string,date:string,message:string}>,message:string} Resultaat-array met success, commits en message.
|
||||
*/
|
||||
public function gitLog(int $limit = 20): array
|
||||
{
|
||||
@@ -221,10 +284,16 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore content to a specific git commit.
|
||||
* Herstel content naar een specifieke git-commit.
|
||||
*
|
||||
* @param string $commitHash Commit hash to restore to
|
||||
* @return array ['success' => bool, 'message' => string]
|
||||
* Controleert de commit-hash, saniteert deze en voert
|
||||
* `git checkout <hash> -- .` uit om de bestanden van die commit
|
||||
* in de working tree te herstellen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $commitHash Commit-hash om naar te herstellen.
|
||||
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||
*/
|
||||
public function gitRestore(string $commitHash): array
|
||||
{
|
||||
@@ -251,7 +320,11 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the content directory has a git repository.
|
||||
* Controleer of de content-map een git-repository bevat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien een .git-map aanwezig is.
|
||||
*/
|
||||
public function hasGitRepo(): bool
|
||||
{
|
||||
@@ -262,6 +335,15 @@ class ContentBackup
|
||||
// Private helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detecteer of git beschikbaar is op het systeem.
|
||||
*
|
||||
* Voert `git --version` uit en controleert de exit-code.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien git uitvoerbaar is.
|
||||
*/
|
||||
private function detectGit(): bool
|
||||
{
|
||||
$result = $this->execGitCapture(['--version']);
|
||||
@@ -269,9 +351,16 @@ class ContentBackup
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command and capture output, error, and exit code separately.
|
||||
* Voer een git-commando uit en capture output, error en exit-code apart.
|
||||
*
|
||||
* @return array ['output' => string, 'error' => string, 'exit' => int]
|
||||
* Gebruikt proc_open met aparte pipes voor stdout, stderr en stdin.
|
||||
* Alle argumenten worden via escapeshellarg ge-escaped.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array<int,string> $args Git-commando-argumenten.
|
||||
* @param string|null $cwd Optioneel. Werkdirectory voor het git-proces.
|
||||
* @return array{output:string,error:string,exit:int} Array met output, error en exit-code.
|
||||
*/
|
||||
private function execGitCapture(array $args, ?string $cwd = null): array
|
||||
{
|
||||
@@ -305,6 +394,19 @@ class ContentBackup
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Voeg een map recursief toe aan een ZipArchive.
|
||||
*
|
||||
* Itereert over alle entries in $dir en voegt mappen (als lege dir) en
|
||||
* bestanden toe onder de opgegeven $prefix in het ZIP-archief.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param ZipArchive $zip ZIP-archief om aan toe te voegen.
|
||||
* @param string $dir Pad naar de bronmap.
|
||||
* @param string $prefix Prefix (pad binnen het ZIP) voor de entries.
|
||||
* @return void
|
||||
*/
|
||||
private function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
|
||||
{
|
||||
$entries = scandir($dir);
|
||||
@@ -325,6 +427,18 @@ class ContentBackup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopieer een map recursief naar een doelmap.
|
||||
*
|
||||
* Maakt de doelmap aan indien nodig en kopieert alle bestanden en
|
||||
* submappen vanuit de bronmap.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $src Pad naar de bronmap.
|
||||
* @param string $dst Pad naar de doelmap.
|
||||
* @return void
|
||||
*/
|
||||
private function copyDir(string $src, string $dst): void
|
||||
{
|
||||
if (!is_dir($src)) {
|
||||
@@ -352,6 +466,16 @@ class ContentBackup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijder een map en alle inhoud recursief.
|
||||
*
|
||||
* Verwijdert eerst alle bestanden en submappen, daarna de map zelf.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $dir Pad naar de te verwijderen map.
|
||||
* @return void
|
||||
*/
|
||||
private function removeDir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
@@ -376,6 +500,17 @@ class ContentBackup
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruim oude content-backups op, waarbij alleen de laatste bewaard blijft.
|
||||
*
|
||||
* Zoekt backups via een glob-patroon op de content-mapnaam met .bak.*
|
||||
* suffix, sorteert op wijzigingstijd (oudste eerst) en verwijdert alle
|
||||
* backups behalve de meest recente.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function cleanupOldBackups(): void
|
||||
{
|
||||
$parentDir = dirname($this->contentDir);
|
||||
|
||||
+180
-11
@@ -1,13 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GeoIP - Country lookup provider chain (Local binary, MMDB, and API)
|
||||
* GeoIP country lookup provider chain.
|
||||
*
|
||||
* Ondersteunt drie providers voor het resolueren van een landcode op basis
|
||||
* van een IP-adres: een lokale binaire database (DB-IP), een MaxMind MMDB
|
||||
* bestand en een externe API. Providers vallen terug op de lokale database
|
||||
* wanneer een lookup geen resultaat oplevert.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class GeoIP
|
||||
{
|
||||
/**
|
||||
* De analytics-configuratie uit config.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array Bevat o.a. geoip_provider, geoip_mmdb_path en geoip_api_url.
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* FileCache instantie voor het cachen van API-lookups.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var FileCache|null
|
||||
*/
|
||||
private ?FileCache $cache = null;
|
||||
|
||||
/**
|
||||
* Initialiseert de GeoIP-provider met de analytics-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $analyticsConfig De "analytics" sectie uit config.json. Default lege array.
|
||||
*/
|
||||
public function __construct(array $analyticsConfig = [])
|
||||
{
|
||||
$this->config = $analyticsConfig;
|
||||
@@ -16,10 +43,16 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve country code (2-letter ISO alpha-2, upper-case) from an IP address
|
||||
* Resolves een 2-letter ISO alpha-2 landcode uit een IP-adres.
|
||||
*
|
||||
* @param string $ip IPv4 or IPv6 address
|
||||
* @return string|null Country code or null if unresolved/private
|
||||
* De gekozen provider wordt gelezen uit de configuratie. Private en
|
||||
* gereserveerde ranges worden direct afgewezen. MMDB en API vallen
|
||||
* terug op de lokale database bij een mislukte lookup.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ip IPv4- of IPv6-adres.
|
||||
* @return string|null Landcode in hoofdletters, of null bij onoplosbare/private adressen.
|
||||
*/
|
||||
public function lookupCountry(string $ip): ?string
|
||||
{
|
||||
@@ -53,7 +86,15 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a country code; placeholder codes (ZZ/XX) count as unknown
|
||||
* Normaliseert een landcode en verwerpt placeholder codes.
|
||||
*
|
||||
* Geldige codes zijn exact twee letters. De codes ZZ en XX worden
|
||||
* beschouwd als onbekend en resulteren in null.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string|null $code Ruwe landcode uit een provider.
|
||||
* @return string|null Genormaliseerde landcode of null indien ongeldig/onbekend.
|
||||
*/
|
||||
private static function normalizeCode(?string $code): ?string
|
||||
{
|
||||
@@ -65,7 +106,16 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary search lookup in local DB-IP IPv4/IPv6 binary files
|
||||
* Binaire zoekopdracht in lokale DB-IP IPv4/IPv6 binaire bestanden.
|
||||
*
|
||||
* Leest de bestanden uit `admin/storage/geoip` en voert een binaire
|
||||
* zoekopdracht uit op basis van het IP-adres. Ondersteunt zowel IPv4
|
||||
* (10-byte records) als IPv6 (34-byte records).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ip IPv4- of IPv6-adres.
|
||||
* @return string|null Landcode in hoofdletters of null indien niet gevonden.
|
||||
*/
|
||||
public function lookupLocal(string $ip): ?string
|
||||
{
|
||||
@@ -161,7 +211,16 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* External API lookup with caching
|
||||
* Externe API-lookup met caching.
|
||||
*
|
||||
* Stuurt een HTTP-verzoek naar de geconfigureerde GeoIP API en cachet
|
||||
* het resultaat zeven dagen in een FileCache. De API-URL mag een
|
||||
* optionele API-key bevatten.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ip IPv4- of IPv6-adres.
|
||||
* @return string|null Landcode of null indien de lookup faalt.
|
||||
*/
|
||||
private function lookupApi(string $ip): ?string
|
||||
{
|
||||
@@ -192,7 +251,16 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-PHP MaxMind MMDB reader
|
||||
* Pure-PHP MaxMind MMDB reader.
|
||||
*
|
||||
* Opent een MMDB-bestand en extraheert de landcode via de ingebouwde
|
||||
* MMDBReader. Fouten worden gesilenced en resulteren in null.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ip IPv4- of IPv6-adres.
|
||||
* @param string $filePath Pad naar het MMDB-bestand.
|
||||
* @return string|null Landcode of null bij een fout.
|
||||
*/
|
||||
private function lookupMMDB(string $ip, string $filePath): ?string
|
||||
{
|
||||
@@ -206,7 +274,12 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 2-letter ISO country code to regional indicator flag emoji
|
||||
* Zet een 2-letter ISO landcode om naar een regionale vlag-emoji.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string|null $code Landcode of null.
|
||||
* @return string Vlag-emoji of een wereldbol bij ongeldige invoer.
|
||||
*/
|
||||
public static function getCountryFlagEmoji(?string $code): string
|
||||
{
|
||||
@@ -222,7 +295,17 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* Get country name in Dutch or English
|
||||
* Geeft de landnaam in Nederlands of Engels.
|
||||
*
|
||||
* Bevat een statische mapping van de meest voorkomende landcodes naar
|
||||
* hun naam in de gevraagde taal. Onbekende codes worden ongewijzigd
|
||||
* teruggegeven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string|null $code Landcode of null.
|
||||
* @param string $lang Gewenste taal, 'nl' of 'en'. Default 'nl'.
|
||||
* @return string Landnaam in de gevraagde taal of de code zelf.
|
||||
*/
|
||||
public static function getCountryName(?string $code, string $lang = 'nl'): string
|
||||
{
|
||||
@@ -275,14 +358,49 @@ class GeoIP
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in pure-PHP MaxMind DB Reader
|
||||
* Ingebouwde pure-PHP MaxMind DB reader.
|
||||
*
|
||||
* Minimale implementatie van de MaxMind DB binary reader, nodig omdat de
|
||||
* externe GeoIP2 dependency optioneel is. Ondersteunt 28-bit record sizes
|
||||
* en de veelvoorkomende datatypes (pointer, string, map, uint, bool).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class MMDBReader
|
||||
{
|
||||
/**
|
||||
* Pad naar het MMDB-bestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private string $file;
|
||||
|
||||
/**
|
||||
* Bestandshandle voor het MMDB-bestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var resource|null
|
||||
*/
|
||||
private $handle;
|
||||
|
||||
/**
|
||||
* Metadata uit de MMDB-header.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array Bevat o.a. node_count, record_size en ip_version.
|
||||
*/
|
||||
private array $meta;
|
||||
|
||||
/**
|
||||
* Opent het MMDB-bestand en laadt de metadata.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $file Pad naar het MMDB-bestand.
|
||||
* @throws \InvalidArgumentException Als het bestand niet bestaat.
|
||||
* @throws \RuntimeException Als het bestandformaat ongeldig is.
|
||||
*/
|
||||
public function __construct(string $file)
|
||||
{
|
||||
if (!file_exists($file)) {
|
||||
@@ -293,6 +411,11 @@ class MMDBReader
|
||||
$this->loadMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sluit de bestandshandle bij het vernietigen van de instantie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
if ($this->handle) {
|
||||
@@ -300,6 +423,16 @@ class MMDBReader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Laadt de MMDB-metadata vanaf het einde van het bestand.
|
||||
*
|
||||
* Zoekt naar de MaxMind marker en decodeert de daaropvolgende data.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @throws \RuntimeException Als de marker niet gevonden wordt.
|
||||
* @return void
|
||||
*/
|
||||
private function loadMetadata(): void
|
||||
{
|
||||
$stat = fstat($this->handle);
|
||||
@@ -319,6 +452,18 @@ class MMDBReader
|
||||
$this->meta = $this->decodeData($metaOffset)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Voert een lookup uit voor een IP-adres in de MMDB-boom.
|
||||
*
|
||||
* Werkt door de binaire boom te doorlopen op basis van de bits van het
|
||||
* IP-adres. IPv4-adressen in een IPv6-boom worden correct afgehandeld
|
||||
* via de ipv4_instance_count metadata.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ip IPv4- of IPv6-adres.
|
||||
* @return array|null Gedecodeerde datarecord of null indien niet gevonden.
|
||||
*/
|
||||
public function get(string $ip): ?array
|
||||
{
|
||||
$ipBin = inet_pton($ip);
|
||||
@@ -355,6 +500,18 @@ class MMDBReader
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leest een kind-node (left/right) uit de MMDB-boom.
|
||||
*
|
||||
* Ondersteunt uitsluitend 28-bit record sizes.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $node Huidig node-index.
|
||||
* @param int $bit Bitwaarde (0 voor left, 1 voor right).
|
||||
* @param int $recordSize Record size in bits.
|
||||
* @return int Doel-node-index of 0 bij niet-ondersteunde record sizes.
|
||||
*/
|
||||
private function readNode(int $node, int $bit, int $recordSize): int
|
||||
{
|
||||
$bytesPerRecord = $recordSize / 4; // 28-bit -> 3.5 bytes per record
|
||||
@@ -372,6 +529,18 @@ class MMDBReader
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodeert een MMDB-datarecord vanaf de gegeven offset.
|
||||
*
|
||||
* Ondersteunt pointer, string, double, uint, map en bool. De array
|
||||
* return value bevat de gedecodeerde waarde en de cursorpositie na
|
||||
* het record.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $offset Start-offset in het bestand.
|
||||
* @return array Pair [mixed $value, int $newOffset].
|
||||
*/
|
||||
private function decodeData(int $offset): array
|
||||
{
|
||||
fseek($this->handle, $offset);
|
||||
|
||||
+201
-35
@@ -1,34 +1,105 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* LogManager - Dynamic logging for CodePress CMS
|
||||
* Dynamische logging voor 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.
|
||||
* Ondersteunt drie drivers:
|
||||
* - syslog: Stuurt log entries naar een remote syslog-server (UDP) of de
|
||||
* lokale syslog.
|
||||
* - sqlite: Slaat log entries op in een SQLite-database (standaard wanneer
|
||||
* geen syslog-server geconfigureerd is en SQLite beschikbaar is).
|
||||
* - file: Fallback naar platte-tekstbestanden wanneer SQLite niet
|
||||
* beschikbaar is.
|
||||
*
|
||||
* Which event types are recorded is controlled dynamically via the
|
||||
* "logging.events" config section (admin, requests, errors, security,
|
||||
* Welke event-types geregistreerd worden, wordt dynamisch gestuurd via de
|
||||
* "logging.events" configuratiesectie (admin, requests, errors, security,
|
||||
* content, system).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class LogManager {
|
||||
/**
|
||||
* Event-type voor admin-acties.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const EVENT_ADMIN = 'admin';
|
||||
|
||||
/**
|
||||
* Event-type voor request-logging.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const EVENT_REQUESTS = 'requests';
|
||||
|
||||
/**
|
||||
* Event-type voor fouten en waarschuwingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const EVENT_ERRORS = 'errors';
|
||||
|
||||
/**
|
||||
* Event-type voor beveiligingsgebeurtenissen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const EVENT_SECURITY = 'security';
|
||||
|
||||
/**
|
||||
* Event-type voor content-wijzigingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const EVENT_CONTENT = 'content';
|
||||
|
||||
/**
|
||||
* Event-type voor systeemgebeurtenissen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const EVENT_SYSTEM = 'system';
|
||||
|
||||
/**
|
||||
* De "logging" configuratiesectie, of null indien niet geïnitialiseerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array|null
|
||||
*/
|
||||
private static $config = null;
|
||||
|
||||
/**
|
||||
* Lazy-initialized PDO-verbinding met de SQLite-database, of null.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var \PDO|null
|
||||
*/
|
||||
private static $pdo = null;
|
||||
|
||||
/**
|
||||
* Pad naar het SQLite-databasebestand, of null indien niet geïnitialiseerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string|null
|
||||
*/
|
||||
private static $dbPath = null;
|
||||
|
||||
/**
|
||||
* Initialize the log manager with the logging config section.
|
||||
* Initialiseert de log-manager met de logging-configuratie.
|
||||
*
|
||||
* @param array $loggingConfig The "logging" section from config.json
|
||||
* Slaat de configuratie op en stelt het pad naar de SQLite-database in
|
||||
* onder `admin/storage/logs`.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $loggingConfig De "logging" sectie uit config.json.
|
||||
* @return void
|
||||
*/
|
||||
public static function init(array $loggingConfig): void
|
||||
{
|
||||
@@ -37,7 +108,11 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether logging is enabled at all.
|
||||
* Geeft aan of logging in het algemeen ingeschakeld is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien logging ingeschakeld is, anders false.
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
@@ -45,9 +120,15 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a given event type should be recorded.
|
||||
* Geeft aan of een bepaald event-type geregistreerd moet worden.
|
||||
*
|
||||
* @param string $event One of the EVENT_* constants
|
||||
* Controleert eerst of logging in het algemeen ingeschakeld is en
|
||||
* daarna of het specifieke event aan staat in de configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $event Een van de EVENT_* constanten.
|
||||
* @return bool True indien het event geregistreerd moet worden.
|
||||
*/
|
||||
public static function isEventEnabled(string $event): bool
|
||||
{
|
||||
@@ -59,9 +140,15 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Geeft de actieve lokale opslag-driver.
|
||||
*
|
||||
* Retourneert 'sqlite' wanneer SQLite beschikbaar is, anders 'file'.
|
||||
* Syslog is een aanvullende output en vervangt nooit de lokale
|
||||
* opslag, en komt dus nooit als driver terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string 'sqlite' of 'file'.
|
||||
*/
|
||||
public static function getDriver(): string
|
||||
{
|
||||
@@ -75,12 +162,20 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a log entry if the event type is enabled.
|
||||
* Slaat een log-entry op indien het event-type ingeschakeld is.
|
||||
*
|
||||
* @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
|
||||
* De entry wordt altijd lokaal opgeslagen (SQLite of file-fallback) zodat
|
||||
* de dynamische admin-log altijd entries bevat. Wanneer een syslog-host
|
||||
* geconfigureerd is, wordt de entry tevens naar de remote syslog-server
|
||||
* gestuurd. Het IP-adres wordt uit de context of de RequestLogger gehaald.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $event Event-type (een EVENT_* constante).
|
||||
* @param string $level Loglevel (info, warning, error, debug).
|
||||
* @param string $message Logbericht.
|
||||
* @param array $context Aanvullende gestructureerde context. Default lege array.
|
||||
* @return void
|
||||
*/
|
||||
public static function log(string $event, string $level, string $message, array $context = []): void
|
||||
{
|
||||
@@ -115,7 +210,15 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a log entry to a remote syslog server over UDP.
|
||||
* Stuurt een log-entry naar een remote syslog-server over UDP.
|
||||
*
|
||||
* Bouwt een syslog-bericht op met facility, severity en ident en stuurt
|
||||
* dit via een UDP-socket. Verbindings- en schrijffouten worden gesilenced.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $entry De te versturen log-entry.
|
||||
* @return void
|
||||
*/
|
||||
private static function writeSyslog(array $entry): void
|
||||
{
|
||||
@@ -138,7 +241,16 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a log entry in the SQLite database.
|
||||
* Slaat een log-entry op in de SQLite-database.
|
||||
*
|
||||
* De context-array wordt als JSON opgeslagen. Wanneer de PDO-verbinding
|
||||
* niet beschikbaar is of het schrijven faalt, wordt teruggevallen op
|
||||
* de file-driver.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $entry De op te slaan log-entry.
|
||||
* @return void
|
||||
*/
|
||||
private static function writeSqlite(array $entry): void
|
||||
{
|
||||
@@ -167,7 +279,15 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a log entry to a plain text file (fallback driver).
|
||||
* Voegt een log-entry toe aan een platte-tekstbestand (fallback-driver).
|
||||
*
|
||||
* Zorgt dat de doelmap bestaat en schrijft de entry met een vaste
|
||||
* opmaak. Schrijffouten worden gesilenced.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $entry De toe te voegen log-entry.
|
||||
* @return void
|
||||
*/
|
||||
private static function writeFile(array $entry): void
|
||||
{
|
||||
@@ -182,7 +302,15 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (and lazily create) the PDO connection to the SQLite database.
|
||||
* Geeft (en creëert lazy) de PDO-verbinding met de SQLite-database.
|
||||
*
|
||||
* Maakt de tabel en indices aan indien deze nog niet bestaan. Bij een
|
||||
* fout wordt de interne verbinding op null gezet en null teruggegeven,
|
||||
* zodat de aanroeper kan terugvallen op de file-driver.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return \PDO|null PDO-verbinding of null indien SQLite niet beschikbaar/faalt.
|
||||
*/
|
||||
private static function getPdo(): ?\PDO
|
||||
{
|
||||
@@ -222,7 +350,11 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SQLite PDO driver is available.
|
||||
* Geeft aan of de SQLite PDO-driver beschikbaar is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien PDO en de sqlite-driver beschikbaar zijn.
|
||||
*/
|
||||
private static function sqliteAvailable(): bool
|
||||
{
|
||||
@@ -230,7 +362,16 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a facility name to its syslog numeric value.
|
||||
* Vertaalt een facility-naam naar de bijbehorende numerieke syslog-waarde.
|
||||
*
|
||||
* Ondersteunt de standaard facilities (kern, user, mail, daemon, auth,
|
||||
* syslog, lpr, news, uucp, cron, authpriv, ftp) en local0 t/m local7.
|
||||
* Onbekende faciliteiten vallen terug op local0 (16).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $facility Naam van de facility. Default 'local0' wordt door aanroeper gezet.
|
||||
* @return int Numerieke facility-waarde.
|
||||
*/
|
||||
private static function syslogFacility(string $facility): int
|
||||
{
|
||||
@@ -245,7 +386,15 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a log level to its syslog severity value.
|
||||
* Vertaalt een loglevel naar de bijbehorende syslog-severity.
|
||||
*
|
||||
* Ondersteunt debug t/m emergency. Onbekende levels vallen terug op
|
||||
* info (6). De invoer wordt case-insensitive vergeleken.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $level Loglevel (debug, info, notice, warning, error, ...).
|
||||
* @return int Numerieke severity-waarde (0 t/m 7).
|
||||
*/
|
||||
private static function syslogSeverity(string $level): int
|
||||
{
|
||||
@@ -263,13 +412,22 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query recent log entries from the active store.
|
||||
* Zoekt recente log-entries op uit de actieve opslag.
|
||||
*
|
||||
* @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)
|
||||
* Bij de SQLite-driver wordt een geparametriseerde query uitgevoerd met
|
||||
* optionele filters op event, level en een zoekterm op het bericht.
|
||||
* Bij de file-driver wordt het logbestand uitgelezen en met een regex
|
||||
* geparseerd; de resultaten worden gefilterd en oudste eerst teruggegeven.
|
||||
* Resultaten worden altijd nieuwste eerst geretourneerd (SQLite via
|
||||
* ORDER BY id DESC, file via array_reverse).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $limit Aantal terug te geven entries. Default 200.
|
||||
* @param string|null $event Optioneel event-filter. Default null (geen filter).
|
||||
* @param string|null $level Optioneel levelfilter (info, warning, error, ...). Default null.
|
||||
* @param string|null $search Optionele tekstzoekopdracht op het bericht. Default null.
|
||||
* @return array<int, array<string, mixed>> Lijst met log-entries (nieuwste eerst).
|
||||
*/
|
||||
public static function getLogs(int $limit = 200, ?string $event = null, ?string $level = null, ?string $search = null): array
|
||||
{
|
||||
@@ -340,7 +498,15 @@ class LogManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored log entries.
|
||||
* Wist alle opgeslagen log-entries.
|
||||
*
|
||||
* Bij de SQLite-driver worden alle rijen uit de logs-tabel verwijderd.
|
||||
* Bij de file-driver wordt het logbestand leeggemaakt. Schrijffouten
|
||||
* worden gesilenced.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear(): void
|
||||
{
|
||||
|
||||
+119
-43
@@ -1,27 +1,75 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple Logger Class for CodePress CMS
|
||||
*
|
||||
* Provides structured logging with log levels and file output.
|
||||
*
|
||||
* @package CodePress
|
||||
* @version 1.0.0
|
||||
* Eenvoudige logger voor CodePress CMS.
|
||||
*
|
||||
* Voorziet in gestructureerde logging met loglevels en bestandsuitvoer.
|
||||
* Logregels worden naar een tekstbestand geschreven en, indien aanwezig,
|
||||
* tevens door de dynamische LogManager gerouteerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
|
||||
class Logger {
|
||||
/**
|
||||
* Loglevel voor debug-boodschappen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const DEBUG = 'DEBUG';
|
||||
|
||||
/**
|
||||
* Loglevel voor informatieve boodschappen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const INFO = 'INFO';
|
||||
|
||||
/**
|
||||
* Loglevel voor waarschuwingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const WARNING = 'WARNING';
|
||||
|
||||
/**
|
||||
* Loglevel voor fouten.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
const ERROR = 'ERROR';
|
||||
|
||||
|
||||
/**
|
||||
* Pad naar het logbestand, of null indien nog niet geïnitialiseerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string|null
|
||||
*/
|
||||
private static $logFile = null;
|
||||
|
||||
/**
|
||||
* Of debug-logging actief is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var bool
|
||||
*/
|
||||
private static $debugMode = false;
|
||||
|
||||
/**
|
||||
* Initialize logger
|
||||
*
|
||||
* @param string $logFile Path to log file
|
||||
* @param bool $debugMode Enable debug logging
|
||||
* Initialiseert de logger.
|
||||
*
|
||||
* Stelt het pad naar het logbestand in en zorgt dat de bijbehorende
|
||||
* map bestaat. Indien geen pad wordt opgegeven valt de logger terug
|
||||
* op een standaardpad onder de core-map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string|null $logFile Pad naar het logbestand. Default null (standaardlocatie).
|
||||
* @param bool $debugMode Of debug-logging actief is. Default false.
|
||||
* @return void
|
||||
*/
|
||||
public static function init($logFile = null, $debugMode = false) {
|
||||
if ($logFile === null) {
|
||||
@@ -39,10 +87,13 @@ class Logger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message (only in debug mode)
|
||||
*
|
||||
* @param string $message Message to log
|
||||
* @param array $context Additional context
|
||||
* Logt een debug-boodschap (enkel in debug-modus).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $message Boodschap die gelogd moet worden.
|
||||
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||
* @return void
|
||||
*/
|
||||
public static function debug($message, $context = []) {
|
||||
if (self::$debugMode) {
|
||||
@@ -51,41 +102,59 @@ class Logger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
*
|
||||
* @param string $message Message to log
|
||||
* @param array $context Additional context
|
||||
* Logt een informatieve boodschap.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $message Boodschap die gelogd moet worden.
|
||||
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||
* @return void
|
||||
*/
|
||||
public static function info($message, $context = []) {
|
||||
self::write(self::INFO, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
*
|
||||
* @param string $message Message to log
|
||||
* @param array $context Additional context
|
||||
* Logt een waarschuwing.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $message Boodschap die gelogd moet worden.
|
||||
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||
* @return void
|
||||
*/
|
||||
public static function warning($message, $context = []) {
|
||||
self::write(self::WARNING, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message
|
||||
*
|
||||
* @param string $message Message to log
|
||||
* @param array $context Additional context
|
||||
* Logt een fout.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $message Boodschap die gelogd moet worden.
|
||||
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||
* @return void
|
||||
*/
|
||||
public static function error($message, $context = []) {
|
||||
self::write(self::ERROR, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write log entry to file
|
||||
*
|
||||
* @param string $level Log level
|
||||
* @param string $message Message to log
|
||||
* @param array $context Additional context
|
||||
* Schrijft een logregel naar het bestand en routeert deze door LogManager.
|
||||
*
|
||||
* De context-array wordt als JSON aan de logregel toegevoegd. Fouten bij
|
||||
* het schrijven worden gesilenced (graceful degradation). Wanneer de
|
||||
* LogManager class beschikbaar is, wordt de entry tevens naar de
|
||||
* dynamische log gestuurd (errors/warnings naar EVENT_ERRORS, verder
|
||||
* naar EVENT_SYSTEM).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $level Loglevel (DEBUG, INFO, WARNING, ERROR).
|
||||
* @param string $message Boodschap die gelogd moet worden.
|
||||
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||
* @return void
|
||||
*/
|
||||
private static function write($level, $message, $context = []) {
|
||||
if (self::$logFile === null) {
|
||||
@@ -109,18 +178,22 @@ class Logger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get log file path
|
||||
*
|
||||
* @return string Log file path
|
||||
* Geeft het pad naar het logbestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string|null Pad naar het logbestand of null indien niet geïnitialiseerd.
|
||||
*/
|
||||
public static function getLogFile() {
|
||||
return self::$logFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear log file
|
||||
*
|
||||
* @return bool Success status
|
||||
* Verwijdert het logbestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien het bestand succesvol verwijderd is, anders false.
|
||||
*/
|
||||
public static function clear() {
|
||||
if (self::$logFile && file_exists(self::$logFile)) {
|
||||
@@ -130,13 +203,16 @@ class Logger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last N lines from log file
|
||||
* Geeft de laatste N regels uit het logbestand.
|
||||
*
|
||||
* Reads the file backwards in chunks so large log files never have to be
|
||||
* loaded into memory in their entirety.
|
||||
* Leest het bestand achterwaarts in chunks zodat grote logbestanden
|
||||
* nooit volledig in het geheugen geladen hoeven te worden. De regels
|
||||
* worden inclusief afsluitende newlines teruggegeven, oudste eerst.
|
||||
*
|
||||
* @param int $lines Number of lines to retrieve
|
||||
* @return array Log lines (including trailing newlines, oldest first)
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $lines Aantal terug te halen regels. Default 100.
|
||||
* @return array<string> Logregels (oudste eerst) of lege array indien niet beschikbaar.
|
||||
*/
|
||||
public static function tail($lines = 100) {
|
||||
if (!self::$logFile || !file_exists(self::$logFile)) {
|
||||
|
||||
@@ -1,16 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Limiteert het aantal acties per identifier binnen een tijdvenster.
|
||||
*
|
||||
* Houdt per identifier (zoals een IP of gebruikersnaam) bij hoeveel pogingen
|
||||
* binnen een geconfigureerd tijdvenster zijn gedaan. Oudere pogingen vallen
|
||||
* buiten het venster en worden genegeerd. De teller wordt opgeslagen in een
|
||||
* CacheInterface-implementatie, standaard FileCache.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class RateLimiter {
|
||||
/**
|
||||
* Maximum aantal toegestane pogingen binnen het tijdvenster.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var int
|
||||
*/
|
||||
private int $maxAttempts;
|
||||
|
||||
/**
|
||||
* Lengte van het tijdvenster in seconden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var int
|
||||
*/
|
||||
private int $timeWindow;
|
||||
|
||||
/**
|
||||
* Cache-implementatie waarin de pogingen worden bijgehouden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var CacheInterface
|
||||
*/
|
||||
private CacheInterface $cache;
|
||||
|
||||
/**
|
||||
* Maak een nieuwe RateLimiter aan.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $maxAttempts Maximum aantal pogingen binnen het tijdvenster. Default 10.
|
||||
* @param int $timeWindow Lengte van het tijdvenster in seconden. Default 60.
|
||||
* @param CacheInterface|null $cache Optioneel een eigen cache-implementatie; bij null wordt FileCache gebruikt.
|
||||
*/
|
||||
public function __construct(int $maxAttempts = 10, int $timeWindow = 60, ?CacheInterface $cache = null) {
|
||||
$this->maxAttempts = $maxAttempts;
|
||||
$this->timeWindow = $timeWindow;
|
||||
$this->cache = $cache ?? new FileCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bepaal of een nieuwe poging voor de identifier is toegestaan.
|
||||
*
|
||||
* Verwijder eerst pogingen die buiten het tijdvenster vallen. Wanneer het
|
||||
* aantal resterende pogingen onder het maximum ligt wordt de huidige
|
||||
* poging geregistreerd en true teruggegeven; anders false.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $identifier Unieke sleutel voor de te beperken actor (bijv. IP of gebruikersnaam).
|
||||
* @return bool True wanneer de poging is toegestaan, false wanneer het limiet is bereikt.
|
||||
*/
|
||||
public function isAllowed(string $identifier): bool {
|
||||
$key = 'ratelimit_' . md5($identifier);
|
||||
$attempts = $this->cache->get($key) ?? [];
|
||||
@@ -32,6 +83,17 @@ class RateLimiter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geef het aantal nog resterende pogingen voor de identifier.
|
||||
*
|
||||
* Telt alleen pogingen die binnen het tijdvenster vallen. Het resultaat
|
||||
* is nooit negatief.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $identifier Unieke sleutel voor de te beperken actor.
|
||||
* @return int Aantal resterende pogingen; minimaal 0.
|
||||
*/
|
||||
public function getRemainingAttempts(string $identifier): int {
|
||||
$key = 'ratelimit_' . md5($identifier);
|
||||
$attempts = $this->cache->get($key) ?? [];
|
||||
@@ -44,6 +106,14 @@ class RateLimiter {
|
||||
return max(0, $this->maxAttempts - count($attempts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wis alle geregistreerde pogingen voor een identifier.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $identifier Unieke sleutel voor de te beperken actor.
|
||||
* @return void
|
||||
*/
|
||||
public function reset(string $identifier): void {
|
||||
$key = 'ratelimit_' . md5($identifier);
|
||||
$this->cache->delete($key);
|
||||
|
||||
@@ -1,14 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Logging en analyse van inkomende requests.
|
||||
*
|
||||
* Biedt functionaliteit om inkomende requests naar een logbestand te
|
||||
* schrijven, IP-adressen te anonimiseren, IP's tegen lijsten/CIDR-ranges
|
||||
* te matchen, het client-IP te bepalen en bezoekerstype (bot/mens/AI)
|
||||
* te detecteren.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class RequestLogger
|
||||
{
|
||||
/**
|
||||
* Pad naar het logbestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private string $logFile;
|
||||
|
||||
/**
|
||||
* Maak een nieuwe RequestLogger aan.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $logFile Pad naar het te gebruiken logbestand.
|
||||
*/
|
||||
public function __construct(string $logFile)
|
||||
{
|
||||
$this->logFile = $logFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schrijf een request-regel naar het logbestand.
|
||||
*
|
||||
* De gebruikersagent en referrer worden ontdaan van controle-tekens en
|
||||
* afgekapt tot 500 tekens. De landcode wordt alleen opgenomen wanneer
|
||||
* deze exact twee tekens lang is. De mapstructuur wordt automatisch
|
||||
* aangemaakt indien deze nog niet bestaat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $page Opgevraagde pagina of route.
|
||||
* @param string $ip Client IP-adres.
|
||||
* @param string $userAgent HTTP User-Agent header.
|
||||
* @param string $referrer HTTP Referer header.
|
||||
* @param string $host Hostnaam van het request.
|
||||
* @param string $acceptLanguage HTTP Accept-Language header.
|
||||
* @param string $status Statusaanduiding van het request. Default 'ok'.
|
||||
* @param string|null $country Tweeletterige landcode of null. Default null.
|
||||
* @return void
|
||||
*/
|
||||
public function log(string $page, string $ip, string $userAgent, string $referrer, string $host, string $acceptLanguage, string $status = 'ok', ?string $country = null): void
|
||||
{
|
||||
$dir = dirname($this->logFile);
|
||||
@@ -25,7 +68,16 @@ class RequestLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask the last octet (IPv4) or last block (IPv6) of an IP address
|
||||
* Anonimiseer het laatste deel van een IP-adres.
|
||||
*
|
||||
* Voor IPv4 wordt het laatste octet vervangen door 'x'. Voor IPv6 worden
|
||||
* alleen de eerste vier blokken behouden en de rest vervangen door '::x'.
|
||||
* Ongeldige adressen worden ongewijzigd teruggegeven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ip Het te anonimiseren IP-adres.
|
||||
* @return string Het geanonimiseerde IP-adres of het origineel bij een ongeldig adres.
|
||||
*/
|
||||
public static function anonymizeIp(string $ip): string
|
||||
{
|
||||
@@ -45,13 +97,17 @@ class RequestLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an IP matches any entry in a list of IPs/CIDR ranges.
|
||||
* Controleer of een IP overeenkomt met een entry uit een lijst van IP's of CIDR-ranges.
|
||||
*
|
||||
* Supports exact IPv4/IPv6 addresses and CIDR notation (e.g. 192.168.0.0/16).
|
||||
* Ondersteunt exacte IPv4/IPv6-adressen en CIDR-notatie (bijv. 192.168.0.0/16).
|
||||
* Bij een prefix-lengte van 0 komt ieder IP van dezelfde adresfamilie overeen.
|
||||
* Adresfamilies (IPv4 vs IPv6) worden niet onderling gemengd.
|
||||
*
|
||||
* @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
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ip Het te testen client IP-adres.
|
||||
* @param string[] $list Lijst van IP-adressen en/of CIDR-ranges.
|
||||
* @return bool True wanneer het IP met een entry overeenkomt.
|
||||
*/
|
||||
public static function ipMatchesList(string $ip, array $list): bool
|
||||
{
|
||||
@@ -117,6 +173,18 @@ class RequestLogger
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bepaal het client IP-adres uit de beschikbare request-headers.
|
||||
*
|
||||
* Doorloopt in twee passes een vaste set headers (zoals CF-Connecting-IP,
|
||||
* X-Real-IP, X-Forwarded-For en REMOTE_ADDR). De eerste pass geeft
|
||||
* voorrang aan geldige publieke IP's en slaat interne/reserved ranges
|
||||
* over; de tweede pass is een fallback voor lokale ontwikkelomgevingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Het gedetecteerde client IP-adres, of '127.0.0.1' als fallback.
|
||||
*/
|
||||
public static function getClientIp(): string
|
||||
{
|
||||
$headerKeys = [
|
||||
@@ -165,6 +233,21 @@ class RequestLogger
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecteer het type bezoeker aan de hand van de User-Agent.
|
||||
*
|
||||
* Wanneer de BotGuard-class beschikbaar is wordt de UA geïdentificeerd
|
||||
* en een passend label, badge-kleur en Bootstrap-icon teruggegeven
|
||||
* (ai, search, scraper, generieke bot, lege UA of mens). Bij een
|
||||
* ingelogde gebruiker wordt de naam aan het label toegevoegd. Zonder
|
||||
* BotGuard wordt een algemene bezoeker teruggegeven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $ua De User-Agent string.
|
||||
* @param string $user Optionele gebruikersnaam; bij 'Gast' of leeg wordt geen naam toegevoegd. Default ''.
|
||||
* @return array<string,string> Associatieve array met keys: type, label, badge, icon.
|
||||
*/
|
||||
public static function detectVisitorInfo(string $ua, string $user = ''): array
|
||||
{
|
||||
if (class_exists('BotGuard')) {
|
||||
@@ -202,6 +285,18 @@ class RequestLogger
|
||||
return ['type' => 'unknown', 'label' => 'Bezoeker', 'badge' => 'secondary', 'icon' => 'bi-person'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecteer of het huidige request afkomstig is van een bekende bot.
|
||||
*
|
||||
* Leest de User-Agent uit de request-headers en identificeert deze via
|
||||
* BotGuard wanneer beschikbaar. Alleen de categorieën 'ai', 'search'
|
||||
* en 'scraper' worden als bot beschouwd en als uppercase string
|
||||
* teruggegeven; anders null.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string|null Boticategorie ('AI', 'SEARCH' of 'SCRAPER') of null bij geen bot.
|
||||
*/
|
||||
public static function detectBot(): ?string
|
||||
{
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
@@ -214,6 +309,20 @@ class RequestLogger
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lees de laatste regels uit het logbestand en parse deze.
|
||||
*
|
||||
* De regels worden geparseerd volgens het vaste logformaat en omgezet
|
||||
* naar associatieve arrays met tijd, IP, gebruiker, bezoeker-info, taal,
|
||||
* pagina, UA, referrer, status en landcode. Regels waarvan de host leeg
|
||||
* is of een IP/cli-waarde bevat worden als gebruiker 'Gast' gemarkeerd.
|
||||
* Het resultaat is nieuwste-regel-eerst.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param int $lines Aantal te lezen regels vanaf het einde. Default 100.
|
||||
* @return array<int,array<string,mixed>> Geparseerde logregels, nieuwste eerst. Lege array bij ontbrekend bestand.
|
||||
*/
|
||||
public function getLogs(int $lines = 100): array
|
||||
{
|
||||
if (!file_exists($this->logFile)) {
|
||||
|
||||
+169
-45
@@ -5,24 +5,62 @@ use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
/**
|
||||
* ThemeManager - Resolves and renders the active theme
|
||||
* Beheert het actieve thema en de rendering hiervan.
|
||||
*
|
||||
* 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
|
||||
* Verantwoordelijkheden:
|
||||
* - Bepaalt de actieve thema-map aan de hand van de configuratie.
|
||||
* - Laadt theme.json (titel, default_layout, template-mapping, kleuren).
|
||||
* - Bouwt een Twig-omgeving geworteld in de thema-map.
|
||||
* - Compileert thema-SCSS naar CSS (in assets/css_compiled/).
|
||||
* - Mapt een aangevraagde layout naar een concreet .twig-template, met
|
||||
* fallback naar de default_layout van het thema wanneer de layout
|
||||
* onbekend is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class ThemeManager {
|
||||
/**
|
||||
* Volledige CMS-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array Bevat o.a. 'theme_dir' en 'theme'.
|
||||
*/
|
||||
private $config;
|
||||
|
||||
/**
|
||||
* Absoluut pad naar de actieve thema-map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private $themeDir;
|
||||
|
||||
/**
|
||||
* Ruwe theme.json configuratie-array.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array
|
||||
*/
|
||||
private $themeConfig;
|
||||
|
||||
/**
|
||||
* Twig-omgeving voor het renderen van thema-templates.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var Environment
|
||||
*/
|
||||
private $twig;
|
||||
|
||||
/**
|
||||
* @param array $config Full CMS config (must contain 'theme_dir' and 'theme')
|
||||
* Constructor: initialiseert de thema-manager met de CMS-configuratie.
|
||||
*
|
||||
* Stelt de thema-map, theme.json-configuratie en een Twig-omgeving in.
|
||||
* De Twig-cache staat uit en auto-escape is uitgeschakeld (de thema's
|
||||
* beheren eigen escaping waar nodig).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array $config Volledige CMS-configuratie; moet 'theme_dir' en 'theme' bevatten.
|
||||
*/
|
||||
public function __construct(array $config) {
|
||||
$this->config = $config;
|
||||
@@ -37,55 +75,85 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute path of the active theme directory
|
||||
* Geeft het absolute pad van de actieve thema-map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de thema-map.
|
||||
*/
|
||||
public function getThemeDir(): string {
|
||||
return $this->themeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw theme.json config array
|
||||
* Geeft de ruwe theme.json configuratie-array.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array Theme.json inhoud, of lege array bij afwezigheid.
|
||||
*/
|
||||
public function getThemeConfig(): array {
|
||||
return $this->themeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme title (from theme.json 'title' or 'name')
|
||||
* Geeft de thema-titel.
|
||||
*
|
||||
* Leest eerst 'title' uit theme.json, dan 'name', en als laatste
|
||||
* fallback de mapnaam van het thema.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Thematische titel.
|
||||
*/
|
||||
public function getTitle(): string {
|
||||
return $this->themeConfig['title'] ?? $this->themeConfig['name'] ?? basename($this->themeDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme config section (default_template, background settings, etc.)
|
||||
* Geeft de 'config'-sectie van theme.json.
|
||||
*
|
||||
* Bevat o.a. default_template en achtergrondinstellingen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array Config-sectie, of lege array bij afwezigheid.
|
||||
*/
|
||||
public function getConfig(): array {
|
||||
return $this->themeConfig['config'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme template section (layout key => .twig file mapping)
|
||||
* Geeft de 'template'-sectie van theme.json.
|
||||
*
|
||||
* Bevat de mapping van layout-key naar .twig-bestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,string> Layout-key => .twig-bestand mapping.
|
||||
*/
|
||||
public function getTemplates(): array {
|
||||
return $this->themeConfig['template'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the .twig template file for a requested layout.
|
||||
* Bepaalt het .twig-templatebestand voor een aangevraagde layout.
|
||||
*
|
||||
* Templates are defined in theme.json under the "template" section:
|
||||
* Templates worden gedefinieerd in theme.json onder de "template"-sectie:
|
||||
* { "template": { "full_content": "full_content.twig", ... } }
|
||||
* The default template is defined in the "config" section:
|
||||
* De standaardtemplate staat in de "config"-sectie:
|
||||
* { "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.
|
||||
* Prioriteit:
|
||||
* 1. Als de layout een bekende key is in de "template"-sectie, gebruik
|
||||
* het bijbehorende bestand.
|
||||
* 2. Anders fallback naar config.default_template.
|
||||
* 3. Laatste veiligheidsnet: full_content.twig.
|
||||
*
|
||||
* @param string $layout Requested layout key (e.g. 'left_sidebar')
|
||||
* @return string Template name usable by the Twig loader
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $layout Aangevraagde layout-key (bijv. 'left_sidebar').
|
||||
* @return string Templacenaam bruikbaar voor de Twig-loader.
|
||||
*/
|
||||
public function getTemplateForLayout(string $layout): string {
|
||||
$layout = trim($layout);
|
||||
@@ -111,16 +179,23 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of available layout keys defined in the "template" section.
|
||||
* Geeft de lijst met beschikbare layout-keys uit de "template"-sectie.
|
||||
*
|
||||
* @return array List of layout keys
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string> Lijst met layout-keys.
|
||||
*/
|
||||
public function getLayouts(): array {
|
||||
return array_keys($this->getTemplates());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a template file exists in the theme directory
|
||||
* Controleert of een templatebestand bestaat in de thema-map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $file Bestandsnaam van het template (relatief t.o.v. thema-map).
|
||||
* @return bool True als het bestand bestaat.
|
||||
*/
|
||||
private function templateExists(string $file): bool {
|
||||
$path = $this->themeDir . '/' . ltrim($file, '/');
|
||||
@@ -128,11 +203,13 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a layout template with the given data.
|
||||
* Rendert een layout-template met de gegeven data.
|
||||
*
|
||||
* @param string $layout Requested layout key
|
||||
* @param array $data Template variables
|
||||
* @return string Rendered HTML
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $layout Aangevraagde layout-key.
|
||||
* @param array $data Template-variabelen.
|
||||
* @return string Gerenderde HTML.
|
||||
*/
|
||||
public function render(string $layout, array $data): string {
|
||||
$template = $this->getTemplateForLayout($layout);
|
||||
@@ -140,11 +217,17 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the theme's SCSS to CSS (cached by source mtime).
|
||||
* Compiles from assets/scss/theme.scss to assets/css_compiled/theme.css
|
||||
* Compileert de SCSS van het thema naar CSS (gecacht op bron-mtime).
|
||||
*
|
||||
* @param bool $force Force recompilation
|
||||
* @return string|null Absolute path to the compiled CSS, or null if none
|
||||
* Compileert vanuit assets/scss/theme.scss naar assets/css_compiled/theme.css.
|
||||
* De gecompileerde CSS en een .mtime-cachebestand worden op alleen-lezen
|
||||
* (0444) gezet om handmatige aanpassingen te voorkomen. Bij een fout
|
||||
* wordt deze gelogd en null teruggegeven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param bool $force Forceer hercompilatie ongeacht de cache. Default false.
|
||||
* @return string|null Absoluut pad naar de gecompileerde CSS, of null bij fout/afwezigheid.
|
||||
*/
|
||||
public function compileCss(bool $force = false): ?string {
|
||||
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
||||
@@ -193,8 +276,15 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for the theme CSS.
|
||||
* Uses assets/css_compiled/theme.css (compiled from SCSS by scssphp).
|
||||
* Geeft de publieke URL voor de thema-CSS.
|
||||
*
|
||||
* Gebruikt assets/css_compiled/theme.css (gecompileerd vanuit SCSS door
|
||||
* scssphp). Indien de gecompileerde CSS nog niet bestaat, wordt deze
|
||||
* alsnog gecompileerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string|null Publieke CSS-URL, of null indien niet beschikbaar.
|
||||
*/
|
||||
public function getCssUrl(): ?string {
|
||||
$compiledCss = $this->themeDir . '/assets/css_compiled/theme.css';
|
||||
@@ -212,7 +302,11 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for the theme JS, or null if unavailable.
|
||||
* Geeft de publieke URL voor de thema-JS, of null indien niet beschikbaar.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string|null Publieke JS-URL, of null indien niet beschikbaar.
|
||||
*/
|
||||
public function getJsUrl(): ?string {
|
||||
$jsFile = $this->themeDir . '/assets/js/theme.js';
|
||||
@@ -223,7 +317,12 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for a theme asset.
|
||||
* Geeft de publieke URL voor een thema-asset.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $assetPath Pad naar de asset binnen de assets/-map van het thema.
|
||||
* @return string Publieke URL in de vorm '/themes/<naam>/assets/<path>'.
|
||||
*/
|
||||
private function getThemeAssetUrl(string $assetPath): string {
|
||||
$themeName = basename($this->themeDir);
|
||||
@@ -231,14 +330,22 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if theme has SCSS source file.
|
||||
* Controleert of het thema een SCSS-bronbestand heeft.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True als assets/scss/theme.scss bestaat.
|
||||
*/
|
||||
public function hasScss(): bool {
|
||||
return is_file($this->themeDir . '/assets/scss/theme.scss');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compiled CSS is newer than SCSS source.
|
||||
* Controleert of de gecompileerde CSS nieuwer is dan de SCSS-bron.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True als de CSS-_mtime >= SCSS-mtime, anders false.
|
||||
*/
|
||||
public function isScssCompiled(): bool {
|
||||
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
||||
@@ -252,15 +359,24 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if theme has manual CSS file.
|
||||
* Controleert of het thema een handmatig CSS-bestand heeft.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True als assets/css/theme.css bestaat.
|
||||
*/
|
||||
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.
|
||||
* Geeft een lijst met CSS-bestanden in de assets/css/-map van het thema.
|
||||
*
|
||||
* De css_compiled-map wordt uitgesloten.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string> Lijst met publieke CSS-URLs.
|
||||
*/
|
||||
public function getCssFiles(): array {
|
||||
$cssDir = $this->themeDir . '/assets/css';
|
||||
@@ -281,7 +397,11 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of JS files in theme assets/js/ directory.
|
||||
* Geeft een lijst met JS-bestanden in de assets/js/-map van het thema.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string> Lijst met publieke JS-URLs.
|
||||
*/
|
||||
public function getJsFiles(): array {
|
||||
$jsDir = $this->themeDir . '/assets/js';
|
||||
@@ -302,7 +422,11 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL for favicon if it exists.
|
||||
* Geeft de URL voor de favicon indien deze bestaat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string|null Publieke favicon-URL, of null indien niet beschikbaar.
|
||||
*/
|
||||
public function getFaviconUrl(): ?string {
|
||||
$faviconFile = $this->themeDir . '/assets/img/favicon.svg';
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* Configuratie-loader voor CodePress CMS.
|
||||
*
|
||||
* Laadt config.json (of maakt deze aan op basis van config.json.example of
|
||||
* een ingebouwde standaardconfiguratie), voegt ontbrekende secties
|
||||
* (security, analytics, logging) samen met defaults, converteert relatieve
|
||||
* content_dir-paden naar absoluut, laadt de actieve theme-configuratie en
|
||||
* returnt de uiteindelijke configuratie-array. Bij falen volgt een
|
||||
* minimale fallback-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
|
||||
// Simple configuration loader
|
||||
$configJsonPath = __DIR__ . '/../../config.json';
|
||||
$configExamplePath = __DIR__ . '/../../config.json.example';
|
||||
|
||||
/**
|
||||
* config.json automatisch aanmaken als deze nog niet bestaat.
|
||||
*
|
||||
* Kopieert config.json.example indien aanwezig; anders wordt een ingebouwde
|
||||
* standaardconfiguratie weggeschreven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Auto-create config.json if it does not exist
|
||||
if (!file_exists($configJsonPath)) {
|
||||
if (file_exists($configExamplePath)) {
|
||||
@@ -83,6 +104,15 @@ if (!file_exists($configJsonPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* config.json inladen en ontbrekende secties aanvullen met defaults.
|
||||
*
|
||||
* Voegt default-waarden samen voor security, analytics en logging,
|
||||
* garandeert de excluded_ips-standaardlijst en converteert relatieve
|
||||
* content_dir-paden naar absoluut.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
if (file_exists($configJsonPath)) {
|
||||
$jsonContent = file_get_contents($configJsonPath);
|
||||
$config = json_decode($jsonContent, true);
|
||||
@@ -150,6 +180,11 @@ if (file_exists($configJsonPath)) {
|
||||
$config['content_dir'] = $projectRoot . $config['content_dir'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Actieve theme-configuratie laden vanuit theme.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load active theme
|
||||
$activeTheme = $config['active_theme'] ?? 'default';
|
||||
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
|
||||
@@ -166,6 +201,11 @@ if (file_exists($configJsonPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimale fallback-configuratie wanneer config.json niet geladen kon worden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Fallback to minimal config
|
||||
return [
|
||||
'site_title' => 'CodePress',
|
||||
|
||||
+60
-20
@@ -1,37 +1,52 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* CodePress CMS Core Loader
|
||||
*
|
||||
* This file serves as the central entry point for the CodePress CMS core system.
|
||||
* It loads all essential classes and configuration needed for the CMS to function.
|
||||
*
|
||||
* Architecture:
|
||||
* - config.php: Configuration loader that merges default settings with config.json
|
||||
* - ThemeManager.php: Twig-based theme rendering + SCSS compilation
|
||||
* - CodePressCMS.php: Main CMS class handling content, navigation, search, and rendering
|
||||
*
|
||||
* Usage:
|
||||
* This file is included by public/index.php, which then:
|
||||
* 1. Loads configuration from config.php
|
||||
* 2. Creates a new CodePressCMS instance with the config
|
||||
* 3. Calls render() to output the complete page
|
||||
*
|
||||
* The separation allows for:
|
||||
* - Clean public entry point (public/index.php)
|
||||
* - Reusable core components
|
||||
* - Proper class organization with PHPDoc documentation
|
||||
* CodePress CMS Core Loader.
|
||||
*
|
||||
* Centraal bootstrap-bestand voor de CodePress CMS-core. Laadt alle
|
||||
* essentiële classes en configuratie die nodig zijn voor het CMS:
|
||||
* configuratie, Composer-autoloader, core utility classes, logger,
|
||||
* plugin-systeem, ContentAPI en de hoofdclass CodePressCMS.
|
||||
*
|
||||
* Architectuur:
|
||||
* - config.php: configuratie-loader die defaults samenvoegt met config.json.
|
||||
* - ThemeManager.php: Twig-based theme rendering + SCSS-compilatie.
|
||||
* - CodePressCMS.php: hoofdclass voor content, navigation, search en rendering.
|
||||
*
|
||||
* Gebruik:
|
||||
* Dit bestand wordt geïnclude door public/index.php, dat vervolgens:
|
||||
* 1. Configuratie laadt vanuit config.php.
|
||||
* 2. Een CodePressCMS-instantie aanmaakt met de config.
|
||||
* 3. render() aanroept om de pagina uit te voeren.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuratie-systeem laden: defaults en config.json-merge.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load configuration system - handles default settings and config.json merging
|
||||
require_once 'config.php';
|
||||
|
||||
/**
|
||||
* Composer-autoloader laden indien aanwezig.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load Composer autoloader
|
||||
$autoloader = dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
if (file_exists($autoloader)) {
|
||||
require_once $autoloader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core utility classes laden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load core utility classes
|
||||
require_once 'class/Cache.php';
|
||||
require_once 'class/RateLimiter.php';
|
||||
@@ -41,21 +56,46 @@ require_once 'class/GeoIP.php';
|
||||
require_once 'class/Analytics.php';
|
||||
require_once 'class/ThemeManager.php';
|
||||
|
||||
/**
|
||||
* Logger-klassen laden voor gestructureerde logging met log levels.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load Logger class - structured logging with log levels
|
||||
require_once 'class/Logger.php';
|
||||
require_once 'class/LogManager.php';
|
||||
|
||||
/**
|
||||
* Plugin-systeem laden (interfaces, API's en PluginManager).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load Plugin system
|
||||
require_once 'plugin/PluginAPIInterface.php';
|
||||
require_once 'plugin/CMSAPI.php';
|
||||
require_once 'plugin/AdminPluginAPI.php';
|
||||
require_once 'plugin/PluginManager.php';
|
||||
|
||||
/**
|
||||
* ContentAPI laden: CMS-data-toegang voor PHP content-bestanden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load ContentAPI class - provides CMS data access for PHP content files
|
||||
require_once 'class/ContentAPI.php';
|
||||
|
||||
/**
|
||||
* Hoofdclass laden: content parsing, navigation, search en page rendering.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load main CMS class - handles content parsing, navigation, search, and page rendering
|
||||
require_once 'class/CodePressCMS.php';
|
||||
|
||||
/**
|
||||
* Logger initialiseren (debug-mode kan via config worden ingeschakeld).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Initialize logger (debug mode can be enabled in config)
|
||||
Logger::init();
|
||||
@@ -1,16 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight API wrapper for plugins in the admin context.
|
||||
* Provides read-only access to the site config (no CMS instance needed).
|
||||
* Lichtgewicht API-wrapper voor plugins in de admin-context.
|
||||
*
|
||||
* Biedt alleen-lezen toegang tot de site-config (zonder CMS-instance nodig).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class AdminPluginAPI implements PluginAPIInterface
|
||||
{
|
||||
/**
|
||||
* De site-configuratie als associatieve array.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string,mixed>
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* Het absolute pad naar de project-root.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private string $projectRoot;
|
||||
|
||||
/**
|
||||
* De actieve admin-taalcode (bijv. 'nl', 'en').
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private string $adminLanguage;
|
||||
|
||||
/**
|
||||
* De admin PluginManager-instantie, optioneel via setPluginManager geïnjecteerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginManager|null
|
||||
*/
|
||||
private ?PluginManager $pluginManager = null;
|
||||
|
||||
/**
|
||||
* Construeer een AdminPluginAPI-instantie met de opgegeven site-config.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array<string,mixed> $siteConfig De site-configuratie.
|
||||
* @param string $projectRoot Optioneel absoluut pad naar de project-root.
|
||||
*/
|
||||
public function __construct(array $siteConfig, string $projectRoot = '')
|
||||
{
|
||||
$this->config = $siteConfig;
|
||||
@@ -20,8 +58,13 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the admin PluginManager so plugins can resolve their own
|
||||
* translations through the same fallback chain.
|
||||
* Injecteer de admin PluginManager zodat plugins hun eigen vertalingen
|
||||
* via dezelfde fallback-keten kunnen resolven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param PluginManager $pm De admin PluginManager-instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setPluginManager(PluginManager $pm): void
|
||||
{
|
||||
@@ -29,7 +72,13 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value using dot notation.
|
||||
* Haal een configuratiewaarde op via dot-notatie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Configuratie-sleutel in dot-notatie (bijv. 'language.default').
|
||||
* @param mixed $default Standaardwaarde indien de sleutel niet bestaat.
|
||||
* @return mixed De gevonden waarde of $default indien niet gevonden.
|
||||
*/
|
||||
public function getConfig(string $key, $default = null)
|
||||
{
|
||||
@@ -47,7 +96,11 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project root directory.
|
||||
* Haal de project-rootdirectory op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de project-root.
|
||||
*/
|
||||
public function getProjectRoot(): string
|
||||
{
|
||||
@@ -55,15 +108,30 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content directory path.
|
||||
* Haal het pad naar de contentdirectory op als absoluut pad.
|
||||
*
|
||||
* Relatieve paden uit config.json (bijv. 'content') worden afgezet tegen
|
||||
* de project-root, conform het gedrag van cms/core/config.php.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de contentdirectory.
|
||||
*/
|
||||
public function getContentDir(): string
|
||||
{
|
||||
return $this->config['content_dir'] ?? ($this->projectRoot . '/content');
|
||||
$dir = $this->config['content_dir'] ?? ($this->projectRoot . '/content');
|
||||
if ($dir !== '' && $dir[0] !== DIRECTORY_SEPARATOR && !preg_match('#^[A-Za-z]:[/\\\\]#', $dir)) {
|
||||
$dir = $this->projectRoot . '/' . $dir;
|
||||
}
|
||||
return rtrim($dir, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugins directory path.
|
||||
* Haal het pad naar de pluginsdirectory op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de pluginsdirectory.
|
||||
*/
|
||||
public function getPluginsDir(): string
|
||||
{
|
||||
@@ -71,7 +139,11 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of enabled plugins.
|
||||
* Haal de lijst met ingeschakelde plugins op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,string> Lijst met ingeschakelde plugin-namen.
|
||||
*/
|
||||
public function getEnabledPlugins(): array
|
||||
{
|
||||
@@ -79,7 +151,11 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version info from version.php.
|
||||
* Haal de versie-informatie uit version.php op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,mixed> Versie-informatie met minimaal de sleutel 'version'.
|
||||
*/
|
||||
public function getVersionInfo(): array
|
||||
{
|
||||
@@ -94,8 +170,13 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active admin language code (e.g. 'nl', 'en').
|
||||
* System plugins should use this to resolve their translations.
|
||||
* Haal de actieve admin-taalcode op (bijv. 'nl', 'en').
|
||||
*
|
||||
* System-plugins gebruiken deze om hun vertalingen te resolven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string De actieve admin-taalcode.
|
||||
*/
|
||||
public function getAdminLanguage(): string
|
||||
{
|
||||
@@ -103,14 +184,16 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for a plugin in the admin context.
|
||||
* Haal de vertalingen voor een plugin op in de admin-context.
|
||||
*
|
||||
* Fallback chain (handled by PluginManager):
|
||||
* requested language -> plugin default_language -> empty array.
|
||||
* Fallback-keten (afgehandeld door PluginManager):
|
||||
* aangevraagde taal -> default_language van de plugin -> lege array.
|
||||
*
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @param string|null $lang Override language; defaults to the active admin language
|
||||
* @return array Translations [key => value]
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @param string|null $lang Override-taal; standaard de actieve admin-taal.
|
||||
* @return array<string,string> Vertalingen als [key => value].
|
||||
*/
|
||||
public function getPluginTranslations(string $pluginName, ?string $lang = null): array
|
||||
{
|
||||
@@ -122,12 +205,14 @@ class AdminPluginAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a single key for a plugin in the admin context.
|
||||
* Vertaal een enkele sleutel voor een plugin in de admin-context.
|
||||
*
|
||||
* @param string $key Translation key
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @param string|null $lang Override language; defaults to the active admin language
|
||||
* @return string Translated string, or $key if not found
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Vertaalsleutel.
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @param string|null $lang Override-taal; standaard de actieve admin-taal.
|
||||
* @return string Vertaalde string, of $key indien niet gevonden.
|
||||
*/
|
||||
public function t(string $key, string $pluginName, ?string $lang = null): string
|
||||
{
|
||||
|
||||
+226
-37
@@ -1,18 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Front-end plugin-API voor content-plugins.
|
||||
*
|
||||
* Biedt toegang tot de CodePressCMS-instance, pagina-informatie, menu, zoekresultaten,
|
||||
* vertalingen en bestandsoperaties binnen de front-end context.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class CMSAPI implements PluginAPIInterface
|
||||
{
|
||||
/**
|
||||
* De CodePressCMS-hoofdinstantie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var CodePressCMS
|
||||
*/
|
||||
private CodePressCMS $cms;
|
||||
|
||||
/**
|
||||
* De front-end PluginManager-instantie, optioneel via setPluginManager geïnjecteerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginManager|null
|
||||
*/
|
||||
private ?PluginManager $pluginManager = null;
|
||||
|
||||
/**
|
||||
* Construeer een CMSAPI-instantie met de opgegeven CodePressCMS.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param CodePressCMS $cms De CodePressCMS-hoofdinstantie.
|
||||
*/
|
||||
public function __construct(CodePressCMS $cms)
|
||||
{
|
||||
$this->cms = $cms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the front-end PluginManager so content plugins can resolve
|
||||
* their own translations through the same fallback chain.
|
||||
* Injecteer de front-end PluginManager zodat content-plugins hun eigen
|
||||
* vertalingen via dezelfde fallback-keten kunnen resolven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param PluginManager $pm De front-end PluginManager-instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setPluginManager(PluginManager $pm): void
|
||||
{
|
||||
@@ -20,7 +53,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page information
|
||||
* Haal de informatie van de huidige pagina op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,mixed> Pagina-informatie van de huidige pagina.
|
||||
*/
|
||||
public function getCurrentPage(): array
|
||||
{
|
||||
@@ -28,7 +65,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page title
|
||||
* Haal de titel van de huidige pagina op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string De titel van de huidige pagina, of lege string.
|
||||
*/
|
||||
public function getCurrentPageTitle(): string
|
||||
{
|
||||
@@ -37,7 +78,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page content
|
||||
* Haal de inhoud van de huidige pagina op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string De HTML-inhoud van de huidige pagina, of lege string.
|
||||
*/
|
||||
public function getCurrentPageContent(): string
|
||||
{
|
||||
@@ -46,7 +91,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page URL
|
||||
* Haal de URL van de huidige pagina op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string De URL van de huidige pagina inclusief query-parameters.
|
||||
*/
|
||||
public function getCurrentPageUrl(): string
|
||||
{
|
||||
@@ -56,7 +105,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get menu structure
|
||||
* Haal de menustructuur op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,array<string,mixed>> De menustructuur.
|
||||
*/
|
||||
public function getMenu(): array
|
||||
{
|
||||
@@ -64,7 +117,13 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
* Haal een configuratiewaarde op via dot-notatie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Configuratie-sleutel in dot-notatie (bijv. 'language.default').
|
||||
* @param mixed $default Standaardwaarde indien de sleutel niet bestaat.
|
||||
* @return mixed De gevonden waarde of $default indien niet gevonden.
|
||||
*/
|
||||
public function getConfig(string $key, $default = null)
|
||||
{
|
||||
@@ -82,7 +141,70 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation
|
||||
* Haal de project-rootdirectory op (absoluut, genormaliseerd).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de project-root.
|
||||
*/
|
||||
public function getProjectRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal het pad naar de contentdirectory op (absoluut, genormaliseerd).
|
||||
*
|
||||
* De CMS-config lost relatieve content_dir-waarden al op
|
||||
* (zie cms/core/config.php), dus deze wordt as-is teruggegeven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de contentdirectory.
|
||||
*/
|
||||
public function getContentDir(): string
|
||||
{
|
||||
return rtrim($this->cms->config['content_dir'] ?? ($this->getProjectRoot() . '/content'), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal het pad naar de pluginsdirectory op (absoluut, genormaliseerd).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de pluginsdirectory.
|
||||
*/
|
||||
public function getPluginsDir(): string
|
||||
{
|
||||
return $this->getProjectRoot() . '/plugins';
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal de versie-informatie uit version.php op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,mixed> Versie-informatie met minimaal de sleutel 'version'.
|
||||
*/
|
||||
public function getVersionInfo(): array
|
||||
{
|
||||
$versionFile = $this->getProjectRoot() . '/version.php';
|
||||
if (file_exists($versionFile)) {
|
||||
$data = include $versionFile;
|
||||
if (is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
return ['version' => '0.0.0'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal een vertaling op voor de opgegeven sleutel.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Vertaalsleutel.
|
||||
* @return string De vertaalde string, of $key indien niet gevonden.
|
||||
*/
|
||||
public function translate(string $key): string
|
||||
{
|
||||
@@ -90,7 +212,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
* Haal de huidige content-taal op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string De huidige taalcode (bijv. 'nl', 'en').
|
||||
*/
|
||||
public function getCurrentLanguage(): string
|
||||
{
|
||||
@@ -98,7 +224,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is on homepage
|
||||
* Controleer of de gebruiker zich op de homepage bevindt.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien de huidige pagina de default_page is.
|
||||
*/
|
||||
public function isHomepage(): bool
|
||||
{
|
||||
@@ -108,7 +238,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file info for current page
|
||||
* Haal de bestandsinformatie van de huidige pagina op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,mixed>|null Bestandsinformatie of null indien niet aanwezig.
|
||||
*/
|
||||
public function getCurrentPageFileInfo(): ?array
|
||||
{
|
||||
@@ -117,7 +251,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breadcrumb data
|
||||
* Haal de breadcrumb-HTML op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string De gegenereerde breadcrumb-HTML.
|
||||
*/
|
||||
public function getBreadcrumb(): string
|
||||
{
|
||||
@@ -125,7 +263,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content directory has content
|
||||
* Controleer of de contentdirectory inhoud bevat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien de contentdirectory niet leeg is.
|
||||
*/
|
||||
public function hasContent(): bool
|
||||
{
|
||||
@@ -133,7 +275,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search results if searching
|
||||
* Haal de zoekresultaten op indien een zoekopdracht actief is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,array<string,mixed>> De zoekresultaten, of een lege array.
|
||||
*/
|
||||
public function getSearchResults(): array
|
||||
{
|
||||
@@ -144,7 +290,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently searching
|
||||
* Controleer of momenteel een zoekopdracht wordt uitgevoerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return bool True indien de search-query-parameter aanwezig is.
|
||||
*/
|
||||
public function isSearching(): bool
|
||||
{
|
||||
@@ -152,7 +302,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages
|
||||
* Haal de beschikbare talen op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,string> Lijst met beschikbare taalcodes.
|
||||
*/
|
||||
public function getAvailableLanguages(): array
|
||||
{
|
||||
@@ -160,7 +314,13 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for page
|
||||
* Maak een URL voor een pagina.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $page Pagina-identifier.
|
||||
* @param string|null $lang Taalcode; standaard de huidige content-taal.
|
||||
* @return string De gegenereerde pagina-URL met query-parameters.
|
||||
*/
|
||||
public function createUrl(string $page, ?string $lang = null): string
|
||||
{
|
||||
@@ -169,7 +329,15 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute PHP file and capture output
|
||||
* Voer een PHP-bestand uit en vang de output op.
|
||||
*
|
||||
* Het bestand moet binnen de CMS-directory liggen om willekeurige
|
||||
* file-inclusion te voorkomen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $filePath Absoluut pad naar het PHP-bestand.
|
||||
* @return string De opgevangen output, of lege string indien ongeldig.
|
||||
*/
|
||||
public function executePhpFile(string $filePath): string
|
||||
{
|
||||
@@ -190,7 +358,12 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content from PHP/HTML/Markdown file
|
||||
* Haal de inhoud op uit een PHP-, HTML- of Markdown-bestand.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $filePath Pad naar het bestand.
|
||||
* @return string De bestandsinhoud (gerenderd indien PHP/Markdown), of lege string.
|
||||
*/
|
||||
public function getFileContent(string $filePath): string
|
||||
{
|
||||
@@ -215,7 +388,12 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists in content directory
|
||||
* Controleer of een bestand in de contentdirectory bestaat.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $filename Bestandsnaam relatief ten opzichte van de contentdirectory.
|
||||
* @return bool True indien het bestand bestaat.
|
||||
*/
|
||||
public function contentFileExists(string $filename): bool
|
||||
{
|
||||
@@ -224,7 +402,11 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages with their metadata
|
||||
* Haal alle content-items (mappen + bestanden) op als lijst met entry-arrays.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,array<string,mixed>> Lijst met entries ['path' => ..., 'title' => ..., 'type' => ...].
|
||||
*/
|
||||
public function getAllPages(): array
|
||||
{
|
||||
@@ -232,10 +414,13 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the author metadata for the current page.
|
||||
* Returns author_name, author_email and created from the page frontmatter.
|
||||
* Haal de auteursmetadata van de huidige pagina op.
|
||||
*
|
||||
* @return array Author metadata with keys: author_name, author_email, created
|
||||
* Geeft author_name, author_email en created terug uit de pagina-frontmatter.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,string> Auteursmetadata met sleutels: author_name, author_email, created.
|
||||
*/
|
||||
public function getPageAuthor(): array
|
||||
{
|
||||
@@ -249,15 +434,17 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for a plugin in the front-end context.
|
||||
* Haal de vertalingen voor een plugin op in de front-end context.
|
||||
*
|
||||
* Content plugins follow the current content language. Fallback chain
|
||||
* (handled by PluginManager): requested language -> plugin
|
||||
* default_language -> empty array.
|
||||
* Content-plugins volgen de huidige content-taal. Fallback-keten
|
||||
* (afgehandeld door PluginManager): aangevraagde taal ->
|
||||
* default_language van de plugin -> lege array.
|
||||
*
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @param string|null $lang Override language; defaults to the current content language
|
||||
* @return array Translations [key => value]
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @param string|null $lang Override-taal; standaard de huidige content-taal.
|
||||
* @return array<string,string> Vertalingen als [key => value].
|
||||
*/
|
||||
public function getPluginTranslations(string $pluginName, ?string $lang = null): array
|
||||
{
|
||||
@@ -269,12 +456,14 @@ class CMSAPI implements PluginAPIInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a single key for a plugin in the front-end context.
|
||||
* Vertaal een enkele sleutel voor een plugin in de front-end context.
|
||||
*
|
||||
* @param string $key Translation key
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @param string|null $lang Override language; defaults to the current content language
|
||||
* @return string Translated string, or $key if not found
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Vertaalsleutel.
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @param string|null $lang Override-taal; standaard de huidige content-taal.
|
||||
* @return string Vertaalde string, of $key indien niet gevonden.
|
||||
*/
|
||||
public function t(string $key, string $pluginName, ?string $lang = null): string
|
||||
{
|
||||
|
||||
@@ -1,33 +1,89 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Interface for plugin API objects.
|
||||
* Both CMSAPI (front-end) and AdminPluginAPI (admin) implement this.
|
||||
* Interface voor plugin-API-objecten.
|
||||
*
|
||||
* Zowel CMSAPI (front-end) als AdminPluginAPI (admin) implementeren deze interface.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
interface PluginAPIInterface
|
||||
{
|
||||
/**
|
||||
* Haal een configuratiewaarde op via dot-notatie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Configuratie-sleutel in dot-notatie (bijv. 'language.default').
|
||||
* @param mixed $default Standaardwaarde indien de sleutel niet bestaat.
|
||||
* @return mixed De gevonden waarde of $default indien niet gevonden.
|
||||
*/
|
||||
public function getConfig(string $key, $default = null);
|
||||
|
||||
/**
|
||||
* Get the translations for a plugin in the current context.
|
||||
* Haal de project-rootdirectory op (absoluut, genormaliseerd).
|
||||
*
|
||||
* System plugins resolve against the admin language; content plugins
|
||||
* against the current content language. The implementation handles the
|
||||
* fallback to the plugin's default_language.
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @param string|null $lang Override language (optional)
|
||||
* @return array Translations [key => value]
|
||||
* @return string Absoluut pad naar de project-root.
|
||||
*/
|
||||
public function getProjectRoot(): string;
|
||||
|
||||
/**
|
||||
* Haal het pad naar de contentdirectory op (absoluut, genormaliseerd).
|
||||
*
|
||||
* Bij een relatieve configuratiewaarde wordt deze afgezet tegen de project-root.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de contentdirectory.
|
||||
*/
|
||||
public function getContentDir(): string;
|
||||
|
||||
/**
|
||||
* Haal het pad naar de pluginsdirectory op (absoluut, genormaliseerd).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return string Absoluut pad naar de pluginsdirectory.
|
||||
*/
|
||||
public function getPluginsDir(): string;
|
||||
|
||||
/**
|
||||
* Haal de versie-informatie uit version.php op als associatieve array.
|
||||
*
|
||||
* Bevat altijd minimaal de sleutel 'version'.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,mixed> Versie-informatie met minimaal de sleutel 'version'.
|
||||
*/
|
||||
public function getVersionInfo(): array;
|
||||
|
||||
/**
|
||||
* Haal de vertalingen voor een plugin op in de huidige context.
|
||||
*
|
||||
* System-plugins resolven tegen de admin-taal; content-plugins tegen de
|
||||
* huidige content-taal. De implementatie regelt de fallback naar de
|
||||
* default_language van de plugin.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @param string|null $lang Optionele override-taal.
|
||||
* @return array<string,string> Vertalingen als [key => value].
|
||||
*/
|
||||
public function getPluginTranslations(string $pluginName, ?string $lang = null): array;
|
||||
|
||||
/**
|
||||
* Translate a single key for a plugin in the current context.
|
||||
* Vertaal een enkele sleutel voor een plugin in de huidige context.
|
||||
*
|
||||
* @param string $key Translation key
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @param string|null $lang Override language (optional)
|
||||
* @return string Translated string, or $key if not found
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $key Vertaalsleutel.
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @param string|null $lang Optionele override-taal.
|
||||
* @return string Vertaalde string, of $key indien niet gevonden.
|
||||
*/
|
||||
public function t(string $key, string $pluginName, ?string $lang = null): string;
|
||||
}
|
||||
@@ -1,15 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Beheert het laden en hooks-systeem van plugins.
|
||||
*
|
||||
* Laadt ingeschakelde plugins uit de pluginsdirectory, registreert automatisch
|
||||
* action- en filter-hooks, en biedt methoden voor plugin-configuratie,
|
||||
* vertalingen, admin-routes en sidebar-content.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class PluginManager
|
||||
{
|
||||
/**
|
||||
* Geladen plugin-instanties, geïndexeerd op plugin-naam.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string,object>
|
||||
*/
|
||||
private array $plugins = [];
|
||||
|
||||
/**
|
||||
* Het absolute pad naar de pluginsdirectory.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private string $pluginsPath;
|
||||
|
||||
/**
|
||||
* De geïnjecteerde plugin-API-instantie (CMSAPI of AdminPluginAPI).
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginAPIInterface|null
|
||||
*/
|
||||
private $api = null;
|
||||
|
||||
/**
|
||||
* Lijst met ingeschakelde plugin-namen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<int,string>
|
||||
*/
|
||||
private array $enabledPlugins = [];
|
||||
|
||||
/**
|
||||
* Geregistreerde action-hooks, gegroepeerd per hook-naam en prioriteit.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string,array<int,array<int,callable>>>
|
||||
*/
|
||||
private array $actions = [];
|
||||
|
||||
/**
|
||||
* Geregistreerde filter-hooks, gegroepeerd per hook-naam en prioriteit.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string,array<int,array<int,callable>>>
|
||||
*/
|
||||
private array $filters = [];
|
||||
|
||||
/**
|
||||
* De standaardtaal van de site, gebruikt als fallback voor plugins
|
||||
* zonder eigen default_language in plugin.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var string
|
||||
*/
|
||||
private string $siteDefaultLanguage = 'nl';
|
||||
|
||||
/**
|
||||
* Construeer een PluginManager en laadt direct de ingeschakelde plugins.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginsPath Absoluut pad naar de pluginsdirectory.
|
||||
* @param array<int,string> $enabledPlugins Lijst met ingeschakelde plugin-namen.
|
||||
* @param string $siteDefaultLanguage Standaardtaal van de site.
|
||||
*/
|
||||
public function __construct(string $pluginsPath, array $enabledPlugins = [], string $siteDefaultLanguage = 'nl')
|
||||
{
|
||||
$this->pluginsPath = $pluginsPath;
|
||||
@@ -19,14 +86,31 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the CMS site default language. Used as a fallback when a plugin
|
||||
* does not declare its own `default_language` in plugin.json.
|
||||
* Stel de standaardtaal van de site in.
|
||||
*
|
||||
* Wordt gebruikt als fallback wanneer een plugin geen eigen `default_language`
|
||||
* declareert in plugin.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $lang Taalcode (bijv. 'nl', 'en').
|
||||
* @return void
|
||||
*/
|
||||
public function setSiteDefaultLanguage(string $lang): void
|
||||
{
|
||||
$this->siteDefaultLanguage = $lang !== '' ? $lang : 'nl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stel de plugin-API in en geef deze door aan alle geladen plugins.
|
||||
*
|
||||
* Plugins die een setAPI()-methode implementeren, ontvangen de API-instantie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param PluginAPIInterface $api De plugin-API-instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setAPI($api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
@@ -38,6 +122,16 @@ class PluginManager
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Laad alle ingeschakelde plugins uit de pluginsdirectory.
|
||||
*
|
||||
* Per plugin wordt het hoofdbestand (<naam>.php) geïncludeerd, de plugin-class
|
||||
* geïnstantieerd, en action- en filter-hooks automatisch geregistreerd.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function loadPlugins(): void
|
||||
{
|
||||
if (!is_dir($this->pluginsPath)) {
|
||||
@@ -82,16 +176,45 @@ class PluginManager
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registreer een action-callback voor een hook.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $hook Naam van de action-hook.
|
||||
* @param callable $callback De callback die bij de hook wordt uitgevoerd.
|
||||
* @param int $priority Uitvoer-volgorde; lager wordt eerder uitgevoerd.
|
||||
* @return void
|
||||
*/
|
||||
public function addAction(string $hook, callable $callback, int $priority = 10): void
|
||||
{
|
||||
$this->actions[$hook][$priority][] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registreer een filter-callback voor een hook.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $hook Naam van de filter-hook.
|
||||
* @param callable $callback De callback die de waarde filtert.
|
||||
* @param int $priority Uitvoer-volgorde; lager wordt eerder uitgevoerd.
|
||||
* @return void
|
||||
*/
|
||||
public function addFilter(string $hook, callable $callback, int $priority = 10): void
|
||||
{
|
||||
$this->filters[$hook][$priority][] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voer alle geregistreerde callbacks voor een action-hook uit.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $hook Naam van de action-hook.
|
||||
* @param mixed ...$args Argumenten die aan elke callback worden doorgegeven.
|
||||
* @return void
|
||||
*/
|
||||
public function doAction(string $hook, ...$args): void
|
||||
{
|
||||
if (!isset($this->actions[$hook])) return;
|
||||
@@ -103,6 +226,16 @@ class PluginManager
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pas alle geregistreerde filter-callbacks toe op een waarde.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $hook Naam van de filter-hook.
|
||||
* @param mixed $value De initiële waarde die wordt gefilterd.
|
||||
* @param mixed ...$args Extra argumenten voor elke filter-callback.
|
||||
* @return mixed De gefilterde waarde.
|
||||
*/
|
||||
public function applyFilters(string $hook, $value, ...$args)
|
||||
{
|
||||
if (!isset($this->filters[$hook])) return $value;
|
||||
@@ -115,32 +248,66 @@ class PluginManager
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal een specifieke plugin-instantie op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $name Plugin-naam (directorynaam).
|
||||
* @return object|null De plugin-instantie of null indien niet geladen.
|
||||
*/
|
||||
public function getPlugin(string $name): ?object
|
||||
{
|
||||
return $this->plugins[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal alle geladen plugin-instanties op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<string,object> Plugin-instanties geïndexeerd op naam.
|
||||
*/
|
||||
public function getAllPlugins(): array
|
||||
{
|
||||
return $this->plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal de lijst met ingeschakelde plugin-namen op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,string> Lijst met ingeschakelde plugin-namen.
|
||||
*/
|
||||
public function getEnabledPlugins(): array
|
||||
{
|
||||
return $this->enabledPlugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleer of een plugin is ingeschakeld.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-naam (directorynaam).
|
||||
* @return bool True indien de plugin is ingeschakeld.
|
||||
*/
|
||||
public function isEnabled(string $pluginName): bool
|
||||
{
|
||||
return in_array($pluginName, $this->enabledPlugins, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the runtime config for a plugin: defaults from plugin.json `settings`
|
||||
* merged with overrides from the plugin's config.json.
|
||||
* Haal de runtime-configuratie van een plugin op.
|
||||
*
|
||||
* @param string $pluginName Plugin name (directory name)
|
||||
* @return array Resolved config: [key => value, ...]
|
||||
* Standaardwaarden uit plugin.json `settings` worden samengevoegd met
|
||||
* overrides uit het config.json van de plugin.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-naam (directorynaam).
|
||||
* @return array<string,mixed> Opgeloste configuratie als [key => value].
|
||||
*/
|
||||
public function getPluginConfig(string $pluginName): array
|
||||
{
|
||||
@@ -167,15 +334,17 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default (fallback) language declared by a plugin.
|
||||
* Haal de standaard (fallback) taal van een plugin op.
|
||||
*
|
||||
* Resolved from (in order):
|
||||
* Resolved in volgorde van:
|
||||
* 1. plugin.json `default_language`
|
||||
* 2. CMS site config `language.default`
|
||||
* 2. CMS site-config `language.default`
|
||||
* 3. 'nl'
|
||||
*
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @return string Language code (e.g. 'nl', 'en')
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @return string Taalcode (bijv. 'nl', 'en').
|
||||
*/
|
||||
public function getPluginDefaultLanguage(string $pluginName): string
|
||||
{
|
||||
@@ -190,15 +359,17 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the translations for a plugin for the requested language.
|
||||
* Laad de vertalingen voor een plugin voor de aangevraagde taal.
|
||||
*
|
||||
* Fallback chain: requested language -> plugin default language -> empty array.
|
||||
* Fallback-keten: aangevraagde taal -> standaardtaal van de plugin -> lege array.
|
||||
*
|
||||
* @param string $pluginName Plugin directory name
|
||||
* @param string $lang Requested language code
|
||||
* @param string $context Translation context: 'admin' or 'site' (front-end).
|
||||
* Defaults to 'admin' for system plugins, 'site' for content plugins.
|
||||
* @return array Translations [key => value]
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-directorynaam.
|
||||
* @param string $lang Aangevraagde taalcode.
|
||||
* @param string $context Vertaalcontext: 'admin' of 'site' (front-end).
|
||||
* Standaard 'admin' voor system-plugins, 'site' voor content-plugins.
|
||||
* @return array<string,string> Vertalingen als [key => value].
|
||||
*/
|
||||
public function getPluginTranslations(string $pluginName, string $lang, string $context = 'admin'): array
|
||||
{
|
||||
@@ -232,14 +403,16 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect translations for every loaded plugin for the requested language.
|
||||
* Verzamel vertalingen voor alle geladen plugins voor de aangevraagde taal.
|
||||
*
|
||||
* Returns an associative array keyed by plugin name:
|
||||
* Geeft een associatieve array terug, geïndexeerd op plugin-naam:
|
||||
* ['Statistics' => [...], 'Logs' => [...], ...]
|
||||
*
|
||||
* @param string $lang Requested language code
|
||||
* @param string $context 'admin' or 'site'
|
||||
* @return array Plugin translations keyed by plugin name
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $lang Aangevraagde taalcode.
|
||||
* @param string $context 'admin' of 'site'.
|
||||
* @return array<string,array<string,string>> Plugin-vertalingen geïndexeerd op plugin-naam.
|
||||
*/
|
||||
public function getAllPluginTranslations(string $lang, string $context = 'admin'): array
|
||||
{
|
||||
@@ -252,6 +425,16 @@ class PluginManager
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleer of een plugin viewable is.
|
||||
*
|
||||
* Een plugin is viewable tenzij zijn configuratie 'viewable' expliciet op false zet.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param object $plugin De plugin-instantie.
|
||||
* @return bool True indien de plugin viewable is.
|
||||
*/
|
||||
public function isPluginViewable(object $plugin): bool
|
||||
{
|
||||
if (method_exists($plugin, 'getConfig')) {
|
||||
@@ -261,6 +444,17 @@ class PluginManager
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal de samengevoegde sidebar-content van alle viewable plugins op.
|
||||
*
|
||||
* Elke plugin met een getSidebarContent()-methode levert HTML die wordt
|
||||
* ingepakt in een Bootstrap-card met de plugin-titel.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param array<int,string>|null $allowedPlugins Optionele whitelist van plugin-namen.
|
||||
* @return string De samengevoegde sidebar-HTML.
|
||||
*/
|
||||
public function getSidebarContent(?array $allowedPlugins = null): string
|
||||
{
|
||||
$sidebarContent = '';
|
||||
@@ -300,9 +494,11 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS URLs from all enabled plugins that provide getCssUrl().
|
||||
* Haal CSS-URL's op van alle ingeschakelde plugins die getCssUrl() implementeren.
|
||||
*
|
||||
* @return array List of CSS URLs
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,string> Lijst met CSS-URL's.
|
||||
*/
|
||||
public function getPluginCssUrls(): array
|
||||
{
|
||||
@@ -319,10 +515,13 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect admin menu items from all enabled plugins that implement getAdminMenu().
|
||||
* Each plugin returns [['route' => 'plugin-name/action', 'label' => 'Label', 'icon' => 'bi-icon']].
|
||||
* Verzamel admin-menu-items van alle plugins die getAdminMenu() implementeren.
|
||||
*
|
||||
* @return array Admin menu items from all plugins
|
||||
* Elke plugin levert [['route' => 'plugin-name/action', 'label' => 'Label', 'icon' => 'bi-icon']].
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @return array<int,array<string,mixed>> Admin-menu-items van alle plugins.
|
||||
*/
|
||||
public function getAdminMenuItems(): array
|
||||
{
|
||||
@@ -341,12 +540,16 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an admin route to a plugin that handles it.
|
||||
* Plugins implement handleAdminRoute(string $action): ?string (returns rendered HTML or null).
|
||||
* Dispatch een admin-route naar de plugin die deze afhandelt.
|
||||
*
|
||||
* @param string $pluginName Plugin name
|
||||
* @param string $action Action/sub-route within the plugin
|
||||
* @return string|null Rendered HTML, or null if plugin doesn't handle it
|
||||
* Plugins implementeren handleAdminRoute(string $action): ?string
|
||||
* (geeft gerenderde HTML of null terug).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $pluginName Plugin-naam.
|
||||
* @param string $action Actie/sub-route binnen de plugin.
|
||||
* @return string|null Gerenderde HTML, of null indien de plugin deze niet afhandelt.
|
||||
*/
|
||||
public function dispatchAdminRoute(string $pluginName, string $action): ?string
|
||||
{
|
||||
@@ -361,10 +564,12 @@ class PluginManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which plugin handles a given admin route.
|
||||
* Bepaal welke plugin een opgegeven admin-route afhandelt.
|
||||
*
|
||||
* @param string $route The admin route (e.g. 'statistics' or 'statistics/details')
|
||||
* @return array|null ['plugin' => name, 'action' => action, 'permission' => required permission] or null
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param string $route De admin-route (bijv. 'statistics' of 'statistics/details').
|
||||
* @return array<string,mixed>|null ['plugin' => naam, 'action' => actie, 'permission' => vereiste permissie] of null.
|
||||
*/
|
||||
public function resolveAdminRoute(string $route): ?array
|
||||
{
|
||||
@@ -382,4 +587,4 @@ class PluginManager
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* Router voor de PHP development-server: clean-URL support en statische bestanden.
|
||||
*
|
||||
* Wordt gebruikt via `php -S localhost:8080 cms/router.php`. Houdt de request-
|
||||
* URI tegen de public/-map voor statische bestanden, serveert theme-, admin-
|
||||
* theme- en plugin-assets buiten public/, en routeert /admin en taal-geprefixte
|
||||
* paden (/nl, /en) door naar admin.php respectievelijk index.php.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
// Router file for PHP development server - clean URL support + static file serving
|
||||
|
||||
$requestUri = $_SERVER['REQUEST_URI'];
|
||||
@@ -19,6 +30,11 @@ $mimeTypes = [
|
||||
'json' => 'application/json',
|
||||
];
|
||||
|
||||
/**
|
||||
* Statische bestanden uit public/ serveren met juiste MIME-type.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Serve static files from public/
|
||||
$filePath = $publicDir . $path;
|
||||
if (is_file($filePath)) {
|
||||
@@ -30,6 +46,13 @@ if (is_file($filePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-assets uit themes/<naam>/ serveren (bijv. /themes/default/js/theme.js).
|
||||
*
|
||||
* Path-traversal wordt afgedwongen via realpath() + prefix-controle.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Serve theme assets from the themes/ directory (e.g. /themes/default/js/theme.js)
|
||||
if (preg_match('#^/themes/([^/]+)/(.+)$#', $path, $m)) {
|
||||
$themeName = $m[1];
|
||||
@@ -50,6 +73,13 @@ if (preg_match('#^/themes/([^/]+)/(.+)$#', $path, $m)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-theme-assets serveren (bijv. /admin/assets/css/bootstrap.min.js).
|
||||
*
|
||||
* Bronmap: admin/theme/default/assets/.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Serve admin theme assets (e.g. /admin/assets/css/bootstrap.min.js)
|
||||
// Served from admin/theme/default/assets/
|
||||
if (preg_match('#^/admin/assets/(.+)$#', $path, $m)) {
|
||||
@@ -70,6 +100,11 @@ if (preg_match('#^/admin/assets/(.+)$#', $path, $m)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-assets serveren (bijv. /plugins/Navigation/assets/css/navigation.css).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Serve plugin assets (e.g. /plugins/Navigation/assets/css/navigation.css)
|
||||
if (preg_match('#^/plugins/([^/]+)/assets/(.+)$#', $path, $m)) {
|
||||
$pluginName = $m[1];
|
||||
@@ -90,6 +125,13 @@ if (preg_match('#^/plugins/([^/]+)/assets/(.+)$#', $path, $m)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-routes doorsturen: /admin/<route> → admin.php?route=<route>.
|
||||
*
|
||||
* Default route is 'dashboard' indien geen subpad opgegeven.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Admin routes: /admin/login → admin.php?route=login
|
||||
if (preg_match('#^/admin(?:/(.+))?$#', $path, $m)) {
|
||||
$_GET['route'] = $m[1] ?? 'dashboard';
|
||||
@@ -97,6 +139,14 @@ if (preg_match('#^/admin(?:/(.+))?$#', $path, $m)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Taal-geprefixte routes: /nl/<page> of /en/<page> → index.php?lang=...&page=...
|
||||
*
|
||||
* /nl/guide activeert de guide-flag met bestaande ?page=. Default valt
|
||||
* door naar index.php.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Language-prefixed routes: /nl/page/path → index.php?lang=nl&page=page/path
|
||||
if (preg_match('#^/(nl|en)(?:/(.+))?$#', $path, $m)) {
|
||||
$_GET['lang'] = $m[1];
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# CodePress CMS v2.6.3 — Release Notes
|
||||
|
||||
**Release datum:** 2026-08-20
|
||||
**Codename:** Lyra
|
||||
**Status:** stable
|
||||
|
||||
---
|
||||
|
||||
## Samenvatting
|
||||
|
||||
Deze release richt zich op **content met meerdere bestandstypes** en een
|
||||
**duidelijkere Content API**. Content bestanden die dezelfde naam delen maar een
|
||||
ander type hebben (`test.md`, `test.php`, `test.html`) worden nu correct
|
||||
afgehandeld: de frontend serveert per extensie het juiste bestand, en de admin
|
||||
accepteert en toont ze allemaal. Daarnaast is de `getAllPages()` API herschreven
|
||||
naar een rijkere array-structuur en zijn de verberg-prefixen voor mappen/bestanden
|
||||
gewijzigd (`.` vervangt `_`).
|
||||
|
||||
---
|
||||
|
||||
## Nieuwe features & wijzigingen
|
||||
|
||||
### 1. Content bestanden met dezelfde naam, ander type
|
||||
|
||||
Eerder kon een content map niet twee bestanden bevatten met dezelfde naam maar
|
||||
een verschillend type (bijv. `test.md` + `test.php`). De frontend routeerde
|
||||
alleen op naam zonder extensie, waardoor slechts één bestand geserveerd werd.
|
||||
|
||||
Nu:
|
||||
|
||||
- **URL met extensie** (`/nl/test.php`) serveert exact dat bestand.
|
||||
- **URL zonder extensie** (`/nl/test`) valt terug op het eerste bestaande bestand
|
||||
met prioriteit **md > php > html** (via de nieuwe `resolveContentByType()`
|
||||
helper).
|
||||
- De admin content editor accepteert bestanden met dezelfde naam (ander type);
|
||||
de "bestaat al" check is per extensie. De melding is verduidelijkt naar
|
||||
"Bestand met dit type bestaat al."
|
||||
- De admin preview-knop linkt per extensie (`test.php` → `/nl/test.php`) zodat
|
||||
elk type individueel te bekijken blijft.
|
||||
|
||||
### 2. Frontend navigatie tonen alle bestandstypes
|
||||
|
||||
`scanDirectory()` (menu), `getDirectoryListing()` (directory view) en
|
||||
`searchInDirectory()` (zoeken) tonen nu elk bestand apart met de extensie
|
||||
behouden in de URL. Bestanden met dezelfde naam maar ander type zijn niet meer
|
||||
samengevoegd tot één menu-item — elk type krijgt zijn eigen link.
|
||||
|
||||
### 3. getAllPages() array structuur
|
||||
|
||||
`ContentAPI::getAllPages()` (en `CMSAPI::getAllPages()` voor plugins) gaf eerder
|
||||
een associative array `['pad' => 'titel']`. Nu is het een **list van entry
|
||||
arrays**:
|
||||
|
||||
```php
|
||||
[
|
||||
['path' => 'test-map', 'title' => 'Test Map', 'type' => 'folder'],
|
||||
['path' => 'test-map/test.md','title' => 'Nieuwe pagina', 'type' => 'md'],
|
||||
['path' => 'test.md', 'title' => 'Test', 'type' => 'md'],
|
||||
['path' => 'test.php', 'title' => 'Test.', 'type' => 'php'],
|
||||
['path' => 'test.html', 'title' => 'Nieuwe pagina', 'type' => 'html'],
|
||||
]
|
||||
```
|
||||
|
||||
- `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
|
||||
- Mappen krijgen hun eigen entry zodat de boom-structuur zichtbaar blijft.
|
||||
- Bestanden met dezelfde naam maar ander type botsen niet meer (de extensie
|
||||
zit in het pad).
|
||||
|
||||
`autoLinkPageTitles()` is aangepast aan de nieuwe structuur en slaat mappen over.
|
||||
|
||||
### 4. Verberg-prefix logica: `.` i.p.v. `_`
|
||||
|
||||
Vooraf verborg de frontend mappen/bestanden die begonnen met `.`, `-` of `_`.
|
||||
Nu is `_` **geen** verberg-prefix meer — alleen `.` (en `-`) verbergen aan de
|
||||
frontend. Dit maakt `.map` of `.bak` de standaard manier om content te verbergen.
|
||||
|
||||
De admin daarentegen toont **wél** alle `.` bestanden en mappen (`.bak`,
|
||||
`.map`), behalve `.git`/`.gitkeep`. De `scanEditorFilesNode()` functie heeft een
|
||||
nieuwe `$scope` parameter die bepaalt of dotfiles getoond worden (alleen voor
|
||||
`content` scope).
|
||||
|
||||
`collectContentPages()` (admin config default-page dropdown) toont nu ook `.`
|
||||
bestanden.
|
||||
|
||||
### 5. ContentAPI getPage()/pageExists() met extensie
|
||||
|
||||
`ContentAPI::getPage('test.php')` en `pageExists('test.php')` respecteren nu de
|
||||
expliciete extensie — ze openen dat specifieke bestand in plaats van terug te
|
||||
vallen op md-prioriteit.
|
||||
|
||||
### 6. Handleiding content-api.md herschreven
|
||||
|
||||
De handleiding `guide/nl/content-beheerder/content-api.md` en de Engelse
|
||||
tegenhanger zijn volledig herschreven:
|
||||
|
||||
- **Hoe content naar Twig gaat**: echo/print vs return mechanisme duidelijk
|
||||
uitgelegd met voorbeelden
|
||||
- **Wat je niet kunt**: geen eigen Twig variabelen vanuit PHP; je voedt alleen
|
||||
`{{ content }}`
|
||||
- **Beschikbare variabelen** in een `.php` bestand: `$api` en `$pageMetadata`
|
||||
- **Nieuwe getAllPages() structuur** met type-veld en voorbeelden
|
||||
- **Layout kiezen**: hoe de `layout` frontmatter key koppelt naar `theme.json`
|
||||
- **Veiligheidsrichtlijnen**
|
||||
|
||||
---
|
||||
|
||||
## Technische details
|
||||
|
||||
### Nieuwe/gewijzigde functies (core)
|
||||
|
||||
- `CodePressCMS::resolveContentByType(string $basePath, ?string $preferredExt = null): ?array`
|
||||
— Nieuwe helper die het eerste bestaande bestand (md > php > html) serveert,
|
||||
of het bestand met de gevraagde extensie als `$preferredExt` opgegeven is.
|
||||
- `CodePressCMS::scanForPageTitles()` — herschreven naar list van
|
||||
`['path','title','type']` entries; mappen als eigen entry; `_` niet meer
|
||||
verborgen.
|
||||
- `CodePressCMS::scanDirectory()` / `searchInDirectory()` / `getDirectoryListing()`
|
||||
— `_` verwijderd uit verberg-logica; extensie behouden in URLs.
|
||||
- `CodePressCMS::autoLinkPageTitles()` — aangepast aan nieuwe array structuur.
|
||||
- `ContentAPI::getPage()` / `pageExists()` — respecteren expliciete extensie.
|
||||
- `ContentAPI::getAllPages()` / `CMSAPI::getAllPages()` — docblocks bijgewerkt.
|
||||
- `scanEditorFilesNode()` — nieuwe `$scope` parameter; content-scope toont
|
||||
dotfiles (behalve .git/.gitkeep).
|
||||
- `scanContentDir()` / `collectContentPages()` — tonen nu `.` bestanden/mappen.
|
||||
|
||||
### Gewijzigde bestanden
|
||||
|
||||
- `cms/core/class/CodePressCMS.php` — resolveContentByType, scanForPageTitles,
|
||||
scanDirectory, searchInDirectory, getDirectoryListing, autoLinkPageTitles,
|
||||
sanitizePageParam (punt toegestaan), getPage (explicitExt terug)
|
||||
- `cms/core/class/ContentAPI.php` — getPage/pageExists met extensie, getAllPages docblock
|
||||
- `cms/core/plugin/CMSAPI.php` — getAllPages docblock
|
||||
- `public/admin.php` — scanContentDir, scanEditorFilesNode, collectContentPages,
|
||||
handleContentNew/handleContentFiles meldingen
|
||||
- `admin/theme/default/views/pages/content-files.twig` — preview link per extensie
|
||||
- `admin/theme/default/views/pages/content-edit.twig` — preview link per extensie
|
||||
- `guide/nl/content-beheerder/content-api.md` — herschreven
|
||||
- `guide/en/content-beheerder/content-api.md` — herschreven
|
||||
- `version.php` — 2.6.3
|
||||
- `README.md` / `README.en.md` — versie 2.6.3
|
||||
- `TODO.md` — v2.6.3 sectie toegevoegd
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- PHP lint: alle bestanden syntactisch correct (`php -l`)
|
||||
- Live getest via Apache (development.codepress.noorlander.info):
|
||||
- `/nl/test.php` serveert test.php
|
||||
- `/nl/test.html` serveert test.html
|
||||
- `/nl/test` (zonder ext) serveert test.md (md prioriteit)
|
||||
- Admin content boom toont `.bak`, `.map`, `_images` en alle bestandstypes
|
||||
- Frontend navigatie verbergt `.bak`/`.map`, toont alle bestandstypes apart
|
||||
- `$api->getAllPages()` in test.php toont pad/titel/type structuur
|
||||
|
||||
---
|
||||
|
||||
## Volgende stappen
|
||||
|
||||
Zie `TODO.md` voor openstaand werk (multidomein implementatie).
|
||||
@@ -0,0 +1,190 @@
|
||||
# CodePress CMS v2.6.5 — Release Notes
|
||||
|
||||
**Release datum:** 2026-08-27
|
||||
**Codename:** Lyra
|
||||
**Status:** stable
|
||||
|
||||
---
|
||||
|
||||
## Samenvatting
|
||||
|
||||
Deze release richt zich op **dynamische pad-resolutie**, **volledige
|
||||
code-documentatie** en een **belangrijke security-fix** rond het standaard
|
||||
admin-wachtwoord. Daarnaast is de git-geschiedenis schoongemaakt van
|
||||
kraakbare wachtwoord-hashes die per ongeluk gecommit waren.
|
||||
|
||||
---
|
||||
|
||||
## Nieuwe features & wijzigingen
|
||||
|
||||
### 1. Dynamische pad-resolutie via de Plugin-API
|
||||
|
||||
Eerder gaf `AdminPluginAPI::getContentDir()` een relatief pad ("content")
|
||||
terug zonder dat te normaliseren tegen de project-root. Omdat Apache
|
||||
`admin.php` uit de `public/` map draait, resolvedde dat relatieve pad naar
|
||||
`public/content` (niet-bestaand) → het dashboard toonde 0 pagina's, 0
|
||||
mappen en 0 B aan content.
|
||||
|
||||
Nu:
|
||||
|
||||
- `PluginAPIInterface` definieert universele pad-methodes
|
||||
(`getProjectRoot()`, `getContentDir()`, `getPluginsDir()`,
|
||||
`getVersionInfo()`) die door zowel `CMSAPI` (frontend) als
|
||||
`AdminPluginAPI` (admin) worden geïmplementeerd.
|
||||
- `AdminPluginAPI::getContentDir()` normaliseert relatieve paden tegen de
|
||||
project-root (met Windows-drive-letter detectie), conform
|
||||
`cms/core/config.php`.
|
||||
- `CMSAPI` heeft deze methodes erbij gekregen (deze ontbraken op de
|
||||
frontend-API).
|
||||
- `public/index.php` media-/assets-serving gebruikt
|
||||
`$config['content_dir']` i.p.v. een hardcoded `/content` pad, zodat een
|
||||
aangepaste `content_dir` in `config.json` wordt gevolgd.
|
||||
- De `Navigation` plugin haalt de content-root via
|
||||
`$this->api->getContentDir()` i.p.v. hardcoded `dirname(__DIR__, 2)`;
|
||||
de guide-root via `getProjectRoot()`.
|
||||
- De `Logs` plugin haalt de log-dir via `$this->api->getProjectRoot()`
|
||||
i.p.v. hardcoded `dirname(__DIR__, 2)`.
|
||||
|
||||
### 2. WordPress-stijl code-documentatie
|
||||
|
||||
Alle classes, methods, properties en functies in de codebase zijn
|
||||
voorzien van volledige docblocks in WordPress-stijl (~450 docblocks):
|
||||
|
||||
```
|
||||
/**
|
||||
* Summary.
|
||||
*
|
||||
* Description.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*
|
||||
* @param type $var Description.
|
||||
* @param array $args {
|
||||
* Optional. An array of arguments.
|
||||
*
|
||||
* @type type $key Description. Default 'value'. Accepts 'value', 'value'.
|
||||
* @type type $key Description.
|
||||
* }
|
||||
* @param type $var Description.
|
||||
* @return type Description.
|
||||
*/
|
||||
```
|
||||
|
||||
Behandelde bestanden: `AdminAuth`, `Cache`, `RateLimiter`, `RequestLogger`,
|
||||
`Analytics`, `BotGuard`, `ContentBackup`, `GeoIP`, `Logger`, `LogManager`,
|
||||
`ThemeManager`, `CodePressCMS`, `PluginAPIInterface`, `CMSAPI`,
|
||||
`AdminPluginAPI`, `PluginManager`, alle 6 plugins, `public/admin.php`
|
||||
(82 functies), en de entrypoints/CLI-bestanden.
|
||||
|
||||
---
|
||||
|
||||
## Security fixes
|
||||
|
||||
### 3. Hardcoded plaintext-wachtwoord verwijderd
|
||||
|
||||
In `admin/src/AdminAuth.php` stond het standaard-wachtwoord letterlijk in
|
||||
de broncode:
|
||||
|
||||
```php
|
||||
'password_hash' => password_hash('admin', PASSWORD_BCRYPT),
|
||||
```
|
||||
|
||||
Deze fallback draaide bij een nieuwe installatie wanneer zowel
|
||||
`admin.json` als `admin.json.example` ontbraken. Het wachtwoord `'admin'`
|
||||
was hierdoor in de gecommitte broncode zichtbaar.
|
||||
|
||||
Nu:
|
||||
|
||||
- Bij eerste installatie wordt een **cryptografisch veilig willekeurig
|
||||
wachtwoord** gegenereerd (`random_bytes`, 16 alfanumerieke tekens).
|
||||
- De bcrypt-hash wordt weggeschreven naar `admin/config/admin.json`.
|
||||
- Het plaintext wachtwoord wordt **eenmalig** op het inlogscherm getoond
|
||||
via een waarschuwing, zodat de beheerder het kan noteren.
|
||||
- Na de eerste succesvolle login is het wachtwoord nergens meer in
|
||||
plaintext opgeslagen.
|
||||
|
||||
Nieuwe methodes in `AdminAuth`:
|
||||
|
||||
- `generateRandomPassword(int $length = 16): string` — CSPRNG-gebaseerd.
|
||||
- `getGeneratedPassword(): string` — geeft het gegenereerde wachtwoord
|
||||
terug voor weergave op de login-pagina.
|
||||
|
||||
### 4. Git-geschiedenis schoongemaakt
|
||||
|
||||
Bij eerdere commits (`d97c67c`, `b8d4a4e`, `5884877`) was
|
||||
`admin/config/admin.json` per ongeluk gecommit voordat het bestand
|
||||
gitignored werd. Dit bestand bevatte een bcrypt-hash die kraakt naar het
|
||||
wachtwoord `'admin'`. Ook `admin/config/admin.json.example` (gecommit)
|
||||
en het oude `admin-console/config/admin.json` pad bevatten dergelijke
|
||||
hashes.
|
||||
|
||||
Uitgevoerd:
|
||||
|
||||
- `git filter-branch` om `admin/config/admin.json`,
|
||||
`admin/config/admin.json.example` en `admin-console/config/admin.json`
|
||||
te verwijderen uit **alle commits** op alle branches en tags.
|
||||
- `reflog expire --expire=now --all` + `gc --prune=now --aggressive` om
|
||||
oude objecten te verwijderen.
|
||||
- Verificatie: beide bekende hashes (`nUpoaCNZZ...`, `CV2uORTI...`) en
|
||||
`password_hash('admin'` komen 0× voor in de hele object-database.
|
||||
|
||||
### 5. admin.json.example verwijderd
|
||||
|
||||
Het gecommitte `admin/config/admin.json.example` bevatte een geldige
|
||||
bcrypt-hash (kraakt naar `'admin'`). Omdat de `AdminAuth`-fallback nu
|
||||
zelf een veilig wachtwoord genereert, is het example-bestand overbodig
|
||||
geworden en verwijderd uit de repo.
|
||||
|
||||
---
|
||||
|
||||
## Bijgewerkte documentatie
|
||||
|
||||
- `README.md` en `README.en.md`: installatie-stappen herschreven (geen
|
||||
`admin.json.example` kopie-stap meer; verwijzing naar
|
||||
`admin`/`admin` inlog verwijderd; uitleg over automatisch gegenereerd
|
||||
wachtwoord bij eerste installatie).
|
||||
- `AGENTS.md`: admin-inlog beschrijving bijgewerkt.
|
||||
- `TODO.md`: v2.6.5 entry toegevoegd.
|
||||
|
||||
---
|
||||
|
||||
## Onder de motorkap
|
||||
|
||||
| Bestand | Wijziging |
|
||||
|---|---|
|
||||
| `cms/core/plugin/PluginAPIInterface.php` | Universele pad-methodes toegevoegd |
|
||||
| `cms/core/plugin/CMSAPI.php` | `getProjectRoot`/`getContentDir`/`getPluginsDir`/`getVersionInfo` toegevoegd |
|
||||
| `cms/core/plugin/AdminPluginAPI.php` | `getContentDir` normaliseert relatieve paden |
|
||||
| `public/index.php` | Media-serving via `$config['content_dir']` |
|
||||
| `plugins/Navigation/Navigation.php` | Content/guide-root via de API |
|
||||
| `plugins/Logs/Logs.php` | Log-dir via de API |
|
||||
| `admin/src/AdminAuth.php` | Random-wachtwoord-generator + `getGeneratedPassword` |
|
||||
| `admin/theme/default/views/login.twig` | Waarschuwing bij eerste installatie |
|
||||
| `version.php` | 2.6.4 → 2.6.5 |
|
||||
| `README.md`, `README.en.md`, `AGENTS.md`, `TODO.md` | Docs bijgewerkt |
|
||||
| ~20 PHP-bestanden | WordPress-stijl docblocks toegevoegd |
|
||||
|
||||
---
|
||||
|
||||
## Tests uitgevoerd
|
||||
|
||||
- `php -l` over alle 67 PHP-bestanden (zonder vendor): 0 syntax-errors.
|
||||
- Apache smoke-test: login (nieuw wachtwoord), dashboard (4 pagina's /
|
||||
2 mappen / 282 KB), content, users, plugins, logs, guide, homepage,
|
||||
pagina en media-serving allen HTTP 200.
|
||||
- Inloggen met oud wachtwoord `admin` werkt niet meer.
|
||||
- Eerste-installatie flow getest: bij afwezigheid van `admin.json` wordt
|
||||
een nieuw wachtwoord gegenereerd en op het inlogscherm getoond.
|
||||
- Git-historie: beide oude hashes en `password_hash('admin'` 0×
|
||||
aanwezig in `git log --all -p`.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade-instructies
|
||||
|
||||
1. Pull de nieuwe versie.
|
||||
2. Als je nog met het standaard-wachtwoord `admin` inlogt: wijzig het
|
||||
direct na upgrade via de admin-interface of
|
||||
`php cli/reset-admin-password.php admin <nieuw-wachtwoord>`.
|
||||
3. optioneel: verwijder `admin/config/admin.json.example` uit je working
|
||||
tree (is niet meer nodig).
|
||||
@@ -1,24 +1,53 @@
|
||||
# Content management
|
||||
|
||||
CodePress offers two views for content management:
|
||||
CodePress uses a **tree view** (`/admin/content`) for content management: a file browser sidebar + CodeMirror editor, uniform with the plugin and theme editors. The old list/table view has been removed.
|
||||
|
||||
1. **Tree view** (`/admin/content`) — default, file browser sidebar + CodeMirror editor (uniform with plugin/theme editors)
|
||||
2. **List view** (`/admin/content-list`) — classic table with filter, upload, new folder/file per folder
|
||||
|
||||
Both work side-by-side and use the same content directory. Switch between views via the "List view"/"Tree view" buttons at the top right. The tree view offers the same functionality as the list view, but with a nested file-tree sidebar like the plugin and theme editors — for a consistent editor experience.
|
||||
|
||||
## Tree view (content)
|
||||
## Content editor
|
||||
|
||||
### File browser sidebar
|
||||
- Shows the nested file tree of `content/`
|
||||
- Shows the nested file tree of `content/`, with a clickable **content** root at the top
|
||||
- Hidden directories (`.bak`, `.git`) and dotfiles are skipped
|
||||
- Click a file to open it in the CodeMirror editor
|
||||
- Click a **file** to open it in the CodeMirror editor
|
||||
- Click a **folder** (the folder name) to select it — the **folder detail pane** appears on the right
|
||||
- The chevron (▶/▼) next to a folder expands/collapses it without selecting the folder
|
||||
- Folders containing the active file are auto-expanded
|
||||
- Per folder there are action buttons: new file, new folder, rename, delete
|
||||
- Per folder there are action buttons: new file (plus icon), new folder (folder-plus icon) — they operate on the selected folder
|
||||
- Per file there are action buttons: rename/move (pencil), delete (trash) — appear on hover
|
||||
|
||||
### Drag-and-drop
|
||||
- Drag a file or folder onto another folder to move it
|
||||
- Valid drop targets are highlighted during dragging
|
||||
- The move is performed via an AJAX call — the page reloads automatically on success
|
||||
- Security: a folder cannot be dropped into itself or a subfolder
|
||||
- Path-traversal protection: all paths stay within the content directory
|
||||
|
||||
### Editable file types
|
||||
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent with the existing content-edit page.
|
||||
|
||||
All types support `---` frontmatter (layout, created, edited, plugins). The CMS `parseMetadata()` extracts frontmatter before processing. For PHP files, frontmatter is stripped from the output.
|
||||
|
||||
#### PHP content files
|
||||
PHP files can deliver content in two ways:
|
||||
1. **Echo** (output buffering): everything `echo`ed or outside `<?php` tags is used as content
|
||||
2. **Return** (callback style): `return '<h1>Hello</h1>';` — the returned string is used as content
|
||||
|
||||
In PHP files, the `ContentAPI` is available via `$api`:
|
||||
```php
|
||||
<?php
|
||||
$menu = $api->getMenu();
|
||||
$pages = $api->getAllPages();
|
||||
$config = $api->getConfig('site_title');
|
||||
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
||||
```
|
||||
|
||||
#### Frontend URLs and file names
|
||||
The CMS searches files in order: `.md` → `.php` → `.html`. If files share the same name (e.g. `test.md` and `test.php`), `/nl/test` always opens `test.md`. To open a specific file type, add the extension to the URL:
|
||||
- `/nl/test` → opens `test.md` (or `.php` / `.html` if `.md` doesn't exist)
|
||||
- `/nl/test.php` → opens `test.php` (explicit extension)
|
||||
- `/nl/test.html` → opens `test.html` (explicit extension)
|
||||
|
||||
The preview link in the editor automatically uses the extension for non-`.md` files.
|
||||
|
||||
### Set image size (Markdown)
|
||||
The markdown editor toolbar has an "Image size" button (expand icon):
|
||||
1. Select an image in the editor in markdown format ``
|
||||
@@ -37,52 +66,53 @@ Existing `{:width=...}` values are shown in the prompts. The size attributes are
|
||||
Images in `content/` are served via the `/-media/` endpoint (content/ lives outside the webroot). Markdown syntax `` with local URLs (relative like `image.jpg`, or absolute like `/content/_images/image.jpg`) is automatically rewritten to `/-media/...`. External URLs (`https://...`) are left untouched.
|
||||
|
||||
### Create a new file
|
||||
- Click **New file** (at the top or per folder in the sidebar)
|
||||
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||
- Click **New file** (in the folder-detail pane or at the top)
|
||||
- Enter a path within content (e.g. `en.page` or `blog/en.post`)
|
||||
- Choose the file type (Markdown/PHP/HTML)
|
||||
- Subfolders are created automatically
|
||||
- Frontmatter with `layout`, `author_name`, `author_email`, `created` is auto-generated
|
||||
- Frontmatter with `layout`, `created`, `edited` is auto-generated
|
||||
- The new file is created in the selected folder
|
||||
|
||||
### Upload a file
|
||||
- Click **Upload** to upload files to `content/`
|
||||
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||
- Click **Upload** (in the folder-detail pane or at the top) to upload files to the selected folder
|
||||
- Allowed: images, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
|
||||
- Path-traversal protection: target dir must stay within `content/`
|
||||
- After the upload you return to the selected folder
|
||||
|
||||
### Move / delete a file
|
||||
- In the file tree each file has a move button (arrows icon) and a delete button (trash)
|
||||
- **Move**: choose a target folder from the dropdown listing all folders in content
|
||||
- **Delete**: with confirmation
|
||||
### Rename / delete a file
|
||||
- Open the file in the editor (click the file name in the sidebar)
|
||||
- **Rename**: edit the **Filename** field on the right above the editor and click Save — the file is renamed immediately
|
||||
- **Delete**: click the delete button (trash) next to the file in the sidebar, with confirmation
|
||||
|
||||
### Folder management
|
||||
- **New folder**: per folder in the sidebar or at the top
|
||||
- **Rename folder**: pencil icon per folder
|
||||
- **Delete folder**: trash icon per folder (only empty folders)
|
||||
- Click a folder in the sidebar to open the **folder-detail pane** on the right
|
||||
- **Rename folder**: edit the **Folder name** field in the folder-detail pane and click Save
|
||||
- **New file / new folder / upload into this folder**: use the buttons in the folder-detail pane — they operate on the selected folder
|
||||
- **Delete folder**: delete button in the folder-detail pane (only empty folders)
|
||||
- The content root is also selectable (click "content" at the top of the tree) — shows the folder-detail pane for the root (rename disabled)
|
||||
|
||||
### Layout selection
|
||||
On the editor page you can choose the layout from the layouts defined in `theme.json` (template mapping). The selected layout is stored in the frontmatter `layout:` key.
|
||||
|
||||
### Plugins on pages
|
||||
- Content plugins (from `plugin.json` with `type: "content"`) appear in the plugin selection
|
||||
- Choose which plugins run on the page
|
||||
- Only active **content plugins** (from `plugin.json` with `type: "content"`) appear in the plugin multiselect; system plugins (like Statistics/Logs/Dashboard) are excluded
|
||||
- Choose which plugins run on the page via the multiselect (use Ctrl/Cmd+click for multiple)
|
||||
- The plugin selection is stored in the frontmatter `plugins:` key
|
||||
|
||||
### Git / Backup integration (Phase 5)
|
||||
At the top of the content editor there are backup and git actions:
|
||||
- **Git init**: initializes a git repository in `content/` (if none exists yet)
|
||||
- **Commit**: commits all uncommitted changes (only if there is a git repo and there are changes)
|
||||
- **Backup**: link to the backup page (`/admin/content-backup`) for ZIP backup/restore
|
||||
- The git status badge shows the current branch, whether there are uncommitted changes, and the last commit
|
||||
### Backup integration
|
||||
At the top of the content editor there is a **Backup** button linking to the backup page (`/admin/content-backup`) for ZIP backup/restore and (if available) git versioning. Git integration in the editor sidebar has been removed; git will become a system plugin later.
|
||||
|
||||
### Frontmatter
|
||||
|
||||
The editor updates the frontmatter live when layout or plugin selection changes:
|
||||
The editor shows the **Created** and **Edited** timestamps (read-only, automatically updated on every save). The `edited:` value is refreshed on each save.
|
||||
|
||||
```markdown
|
||||
---
|
||||
layout: left_sidebar
|
||||
author_name: Admin
|
||||
author_email: admin@example.com
|
||||
created: 2026-08-19 10:30:25
|
||||
edited: 2026-08-20 14:22:01
|
||||
plugins: HTMLBlock, Navigation
|
||||
---
|
||||
|
||||
@@ -91,24 +121,6 @@ plugins: HTMLBlock, Navigation
|
||||
Content...
|
||||
```
|
||||
|
||||
## List view (content)
|
||||
|
||||
### Managing files
|
||||
|
||||
- **New folder** — Create folder structure
|
||||
- **New file** — Create a page (`.md`, `.php`, `.html`)
|
||||
- **Edit** — Modify existing content in the CodeMirror editor
|
||||
- **Rename** — Change file or folder names
|
||||
- **Move** — Move content to another folder
|
||||
- **Delete** — Remove content
|
||||
- **Rename folder** — Change a folder name
|
||||
|
||||
### Editor (content-edit)
|
||||
|
||||
- **CodeMirror** with syntax highlighting (Markdown, PHP, HTML)
|
||||
- **Toolbar** for quickly inserting Markdown
|
||||
- **Shortcuts**: Ctrl+S (save), Ctrl+N (new)
|
||||
|
||||
## Frontmatter
|
||||
|
||||
The editor updates the frontmatter live when layout or plugin selection changes:
|
||||
@@ -116,9 +128,9 @@ The editor updates the frontmatter live when layout or plugin selection changes:
|
||||
```markdown
|
||||
---
|
||||
layout: left_sidebar
|
||||
plugins:
|
||||
- HTMLBlock
|
||||
- Navigation
|
||||
created: 2026-08-19 10:30:25
|
||||
edited: 2026-08-20 14:22:01
|
||||
plugins: HTMLBlock, Navigation
|
||||
---
|
||||
|
||||
# Page title
|
||||
|
||||
@@ -27,4 +27,45 @@ Only active plugins are shown in the admin sidebar.
|
||||
3. Edit the PHP file
|
||||
4. Enable the plugin
|
||||
|
||||
New plugins are content-plugins by default. To create a system plugin, add `"type": "system"` to `plugin.json`.
|
||||
New plugins are content-plugins by default. To create a system plugin, add `"type": "system"` to `plugin.json`.
|
||||
|
||||
## Plugin editor
|
||||
|
||||
Via **Edit** (pencil icon) in the plugin overview you open the plugin editor (`/admin/plugins-edit?plugin=<name>`). It works the same as the content and theme editors.
|
||||
|
||||
### File browser sidebar
|
||||
- Shows the nested file tree of the plugin, with a clickable **plugin** root at the top
|
||||
- Click a file to open it in the CodeMirror editor
|
||||
- Click a folder to open the **folder detail pane** on the right (new file, new folder, upload, delete)
|
||||
- Folders containing the active file are auto-expanded
|
||||
- Per folder there are action buttons: new file, new folder — they operate on the selected folder
|
||||
- Per file there are action buttons: rename/move (pencil), delete (trash) — appear on hover
|
||||
|
||||
### Drag-and-drop
|
||||
- Drag a file or folder onto another folder to move it
|
||||
- Valid drop targets are highlighted during dragging
|
||||
- The move is performed via an AJAX call — the page reloads automatically on success
|
||||
- Protected plugins (like Navigation) cannot be edited/moved
|
||||
|
||||
### Editable file types
|
||||
`.php`, `.json`, `.md`, `.html`, `.css`, `.scss`, `.js`
|
||||
|
||||
### Create a new file / folder
|
||||
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||
- Click **New file** or **New folder** (in the folder-detail pane or at the top)
|
||||
- For a new file: enter a path within the plugin (e.g. `helper.php` or `assets/css/extra.css`)
|
||||
- Subfolders are created automatically
|
||||
|
||||
### Move / delete a file
|
||||
- **Move**: drag the file onto another folder (drag-and-drop), or click the pencil icon
|
||||
- **Delete**: click the trash button next to the file in the sidebar, with confirmation
|
||||
|
||||
### Folder management
|
||||
- Click a folder in the sidebar to open the **folder-detail pane** on the right
|
||||
- **New file / new folder / upload into this folder**: use the buttons in the folder-detail pane
|
||||
- **Delete folder**: delete button in the folder-detail pane (only empty folders)
|
||||
|
||||
### Security
|
||||
- Protected plugins (Navigation) cannot be edited
|
||||
- All actions require a CSRF token
|
||||
- Path-traversal protection via `realpath()` + prefix check on the plugin dir
|
||||
@@ -21,17 +21,27 @@ In the theme overview you see per theme:
|
||||
Via **Edit** (pencil icon) in the theme overview you open the theme editor (`/admin/theme-edit?theme=<name>`). It works the same as the plugin editor:
|
||||
|
||||
### File browser sidebar
|
||||
- Shows the nested file tree of the theme
|
||||
- Shows the nested file tree of the theme, with a clickable **theme** root at the top
|
||||
- `assets/css_compiled/` is hidden (runtime artefact, read-only)
|
||||
- Click a file to open it in the CodeMirror editor
|
||||
- Click a folder to open the **folder detail pane** on the right (new file, new folder, upload, delete)
|
||||
- Folders containing the active file are auto-expanded
|
||||
- Per folder there are action buttons: new file, new folder — they operate on the selected folder
|
||||
- Per file there are action buttons: rename/move (pencil), delete (trash) — appear on hover
|
||||
|
||||
### Drag-and-drop
|
||||
- Drag a file or folder onto another folder to move it
|
||||
- Valid drop targets are highlighted during dragging
|
||||
- The move is performed via an AJAX call — the page reloads automatically on success
|
||||
- `theme.json` cannot be moved
|
||||
|
||||
### Editable file types
|
||||
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
|
||||
|
||||
### Create a new file
|
||||
- Click **New file**
|
||||
- Enter a path within the theme (e.g. `partials/header.twig` or `assets/scss/_variables.scss`)
|
||||
### Create a new file / folder
|
||||
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||
- Click **New file** or **New folder** (in the folder-detail pane or at the top)
|
||||
- For a new file: enter a path within the theme (e.g. `partials/header.twig` or `assets/scss/_variables.scss`)
|
||||
- Subfolders are created automatically
|
||||
- Allowed: twig, json, scss, css, js, html, md, php
|
||||
- A stub is auto-generated (e.g. `{% extends 'base.twig' %}` for `.twig`)
|
||||
@@ -42,9 +52,14 @@ Via **Edit** (pencil icon) in the theme overview you open the theme editor (`/ad
|
||||
- Path-traversal protection: target dir must stay within `assets/`
|
||||
|
||||
### Move / delete a file
|
||||
- In the file tree each file has a move button (arrows icon) and a delete button (trash)
|
||||
- **Move**: choose a target folder from the dropdown listing all folders in the theme
|
||||
- **Delete**: with confirmation; `theme.json` cannot be deleted
|
||||
- **Move**: drag the file onto another folder (drag-and-drop), or click the pencil icon next to the file to go to the move form
|
||||
- **Delete**: click the trash button next to the file in the sidebar, with confirmation; `theme.json` cannot be deleted
|
||||
|
||||
### Folder management
|
||||
- Click a folder in the sidebar to open the **folder-detail pane** on the right
|
||||
- **New file / new folder / upload into this folder**: use the buttons in the folder-detail pane
|
||||
- **Delete folder**: delete button in the folder-detail pane (only empty folders)
|
||||
- The theme root is also selectable (click the theme name at the top of the tree)
|
||||
|
||||
### Compile SCSS from the editor
|
||||
- At the top of the editor there is a **Compile SCSS** button (only if `assets/scss/theme.scss` exists)
|
||||
|
||||
@@ -1,84 +1,132 @@
|
||||
# Content API
|
||||
|
||||
The Content API is available in PHP content files (`.php`) and provides a safe, read-only interface to CMS data.
|
||||
PHP content files (`.php`) run inside the CMS and have access to a safe,
|
||||
read-only API via the `$api` variable. The output of such a file is
|
||||
automatically placed into the active Twig layout at the `{{ content }}` spot.
|
||||
|
||||
## Usage in PHP content files
|
||||
## How to pass content to the Twig template
|
||||
|
||||
There are **two ways** to send content from a `.php` file to the Twig template.
|
||||
The CMS picks the right one automatically:
|
||||
|
||||
1. **Echo / print** — everything you echo (or that sits outside `<?php ?>` tags)
|
||||
is captured and placed as `{{ content }}` in the layout.
|
||||
2. **Return** — if you `return` a string, that string is used as `{{ content }}`
|
||||
(any echoed output is then ignored).
|
||||
|
||||
Example with echo:
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
|
||||
// Get all pages
|
||||
$allPages = $api->getAllPages();
|
||||
// Result: ['index' => 'Home', 'about-us' => 'About Us', ...]
|
||||
|
||||
// Get a specific page
|
||||
$page = $api->getPage('about-us');
|
||||
// Result: ['title' => 'About Us', 'content' => '...', 'path' => 'about-us', 'layout' => 'full_content', 'metadata' => [...]]
|
||||
|
||||
// Get menu structure
|
||||
$menu = $api->getMenu();
|
||||
// Result: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
|
||||
|
||||
// Get config value (dot notation)
|
||||
$siteTitle = $api->getConfig('site_title');
|
||||
$searchEnabled = $api->getConfig('features.search_enabled', false);
|
||||
?>
|
||||
<h1>Hello <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||
<p>Welcome to my page.</p>
|
||||
```
|
||||
|
||||
## Available methods
|
||||
Example with return:
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
return '<h1>Hello ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
|
||||
```
|
||||
|
||||
> The frontmatter (`---` block) is stripped by the CMS before the PHP code
|
||||
> runs. The layout key determines which Twig template surrounds the content.
|
||||
|
||||
## What you cannot do
|
||||
|
||||
- You **cannot** set your own Twig variables from a `.php` file. PHP content
|
||||
only feeds the `{{ content }}` placeholder. Other template variables
|
||||
(`menu`, `breadcrumb`, `page_title`, etc.) are set by the CMS based on
|
||||
frontmatter and config — not by your PHP code.
|
||||
- You **cannot** call a ContentAPI from the outside; the class is only
|
||||
instantiated inside `parsePHP()` and is never reachable via a URL.
|
||||
|
||||
## Available variables in your PHP file
|
||||
|
||||
The following variables are available inside a `.php` content file:
|
||||
|
||||
| Variable | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `$api` | `ContentAPI` | Read-only access to CMS data |
|
||||
| `$pageMetadata` | `array` | The frontmatter metadata of this file |
|
||||
|
||||
## ContentAPI methods
|
||||
|
||||
### Pages
|
||||
|
||||
- `getAllPages(): array` - All pages as `['path' => 'title']` pairs
|
||||
- `getPage(string $path): ?array` - Specific page with `title`, `content`, `path`, `layout`, `metadata`
|
||||
- `pageExists(string $path): bool` - Check if a page exists
|
||||
- `getCurrentPageTitle(): string` - Title of current page
|
||||
- `getCurrentPagePath(): string` - Path of current page
|
||||
- `isHomepage(): bool` - Whether current page is the homepage
|
||||
- `getAllPages(): array` — All content entries (folders + files) as a list of `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` for files, `folder` for directories.
|
||||
- `getPage(string $path): ?array` — A specific page; returns `title`, `content`, `path`, `layout`, `metadata`. `$path` may include an extension.
|
||||
- `pageExists(string $path): bool` — Check whether a page exists
|
||||
- `getCurrentPageTitle(): string` — Title of the current page
|
||||
- `getCurrentPagePath(): string` — Path of the current page
|
||||
- `isHomepage(): bool` — Whether the current page is the homepage
|
||||
|
||||
### Menu & Navigation
|
||||
### Menu & navigation
|
||||
|
||||
- `getMenu(): array` - Hierarchical menu structure with `title`, `path`, `children`, `active`
|
||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Build a URL for a page
|
||||
- `getMenu(): array` — Hierarchical menu structure with `title`, `path`, `children`, `active`
|
||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` — Build a URL
|
||||
|
||||
### Configuration
|
||||
|
||||
- `getConfig(string $key, mixed $default = null): mixed` - Config value via dot notation (e.g. `'features.search_enabled'`)
|
||||
- `getSiteTitle(): string` - Site title from config
|
||||
- `getConfig(string $key, mixed $default = null): mixed` — Config value via dot notation (e.g. `'features.search_enabled'`)
|
||||
- `getSiteTitle(): string` — Site title from config
|
||||
|
||||
### Language
|
||||
|
||||
- `getCurrentLanguage(): string` - Current language code (e.g. `'nl'`)
|
||||
- `getAvailableLanguages(): array` - Available languages (e.g. `['nl', 'en']`)
|
||||
- `t(string $key): string` - Translate a language key
|
||||
- `getCurrentLanguage(): string` — Current language code (e.g. `'nl'`)
|
||||
- `getAvailableLanguages(): array` — Available languages
|
||||
- `t(string $key): string` — Translate a language key
|
||||
|
||||
### Search
|
||||
|
||||
- `getSearchResults(): array` - Search results (empty if not searching)
|
||||
- `isSearching(): bool` - Whether a search is currently active
|
||||
- `getSearchResults(): array` — Search results (empty if not searching)
|
||||
- `isSearching(): bool` — Whether a search is currently active
|
||||
|
||||
## Example: Show recent pages
|
||||
### Author
|
||||
|
||||
- `getPageAuthor(): array` — Author metadata from frontmatter (`author_name`, `author_email`, `created`)
|
||||
|
||||
## Examples
|
||||
|
||||
### Show recent pages
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
$pages = $api->getAllPages();
|
||||
$entries = $api->getAllPages();
|
||||
$currentLang = $api->getCurrentLanguage();
|
||||
?>
|
||||
<ul>
|
||||
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
||||
<?php foreach ($entries as $entry): ?>
|
||||
<?php if ($entry['type'] === 'folder') continue; // skip folders ?>
|
||||
<li>
|
||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
||||
<?= htmlspecialchars($title) ?>
|
||||
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
||||
<?= htmlspecialchars($entry['title']) ?>
|
||||
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Example: Using config values
|
||||
### Use a config value
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
if ($api->getConfig('features.search_enabled', false)): ?>
|
||||
@@ -89,6 +137,44 @@ if ($api->getConfig('features.search_enabled', false)): ?>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Dynamic greeting based on language
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
$lang = $api->getCurrentLanguage();
|
||||
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
|
||||
?>
|
||||
<h1><?= $greeting ?> to <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||
```
|
||||
|
||||
## Choosing a layout
|
||||
|
||||
The frontmatter `layout` key determines which Twig template surrounds your
|
||||
content. Available layouts are defined in `themes/<active-theme>/theme.json`
|
||||
under the `template` section. For example:
|
||||
|
||||
```yaml
|
||||
---
|
||||
layout: sidebar-content
|
||||
---
|
||||
```
|
||||
|
||||
If you omit a layout, `config.default_template` is used, falling back to
|
||||
`full_content`. The content always lands at the `{{ content }}` spot in that
|
||||
template.
|
||||
|
||||
## Security
|
||||
|
||||
- Always use `htmlspecialchars()` for output of user-content or API data.
|
||||
- PHP content files have access to the server filesystem — be careful with
|
||||
`include`, `require` or `file_get_contents` on external paths. Stay inside
|
||||
the `content/` directory.
|
||||
- The ContentAPI is read-only; you cannot modify files or config through it.
|
||||
|
||||
## CMSAPI (for plugins)
|
||||
|
||||
Plugins use the `CMSAPI` class via `$this->api`. It offers similar methods:
|
||||
@@ -108,12 +194,12 @@ $this->api->createUrl('about-us');
|
||||
$this->api->translate('home');
|
||||
```
|
||||
|
||||
Additionally, CMSAPI provides:
|
||||
In addition, CMSAPI has:
|
||||
|
||||
- `getCurrentPage(): array` - Full page data
|
||||
- `getCurrentPageUrl(): string` - URL of current page
|
||||
- `getCurrentPageFileInfo(): ?array` - File info (created, modified)
|
||||
- `getBreadcrumb(): string` - Breadcrumb HTML
|
||||
- `executePhpFile(string $filePath): string` - Execute PHP file and capture output
|
||||
- `getFileContent(string $filePath): string` - Get content from PHP/HTML/Markdown file
|
||||
- `contentFileExists(string $filename): bool` - Check if file exists in content directory
|
||||
- `getCurrentPage(): array` — Full page data
|
||||
- `getCurrentPageUrl(): string` — URL of the current page
|
||||
- `getCurrentPageFileInfo(): ?array` — File info (created, modified)
|
||||
- `getBreadcrumb(): string` — Breadcrumb HTML
|
||||
- `executePhpFile(string $filePath): string` — Execute a PHP file and capture output
|
||||
- `getFileContent(string $filePath): string` — Get content from a PHP/HTML/Markdown file
|
||||
- `contentFileExists(string $filename): bool` — Check whether a file exists in the content directory
|
||||
@@ -1,24 +1,53 @@
|
||||
# Content beheer
|
||||
|
||||
CodePress biedt twee weergaven voor content-beheer:
|
||||
CodePress gebruikt een **boom weergave** (`/admin/content`) voor content-beheer: een bestandsbrowser zijbalk + CodeMirror editor, uniform met de plugin- en thema-editors. De oude lijst/tabel weergave is verwijderd.
|
||||
|
||||
1. **Boom weergave** (`/admin/content`) — standaard, bestandsbrowser zijbalk + CodeMirror editor (uniform met plugin/theme-editors)
|
||||
2. **Lijst weergave** (`/admin/content-list`) — klassieke tabel met filter, upload, nieuwe map/bestand per map
|
||||
|
||||
Beide werken naast elkaar en gebruiken dezelfde content-map. Wissel tussen de weergaven via de "Lijst weergave"/"Boom weergave" knoppen rechtsboven. De boom weergave biedt dezelfde functionaliteit als de lijst weergave, maar dan met een geneste bestandsboom zijbalk zoals de plugin- en thema-editors — voor consistente editor-ervaring.
|
||||
|
||||
## Boom weergave (content)
|
||||
## Content editor
|
||||
|
||||
### Bestandsbrowser zijbalk
|
||||
- Toont de geneste bestandsboom van `content/`
|
||||
- Toont de geneste bestandsboom van `content/`, met een klikbare **content** root bovenaan
|
||||
- Verborgen mappen (`.bak`, `.git`) en dotfiles worden overgeslagen
|
||||
- Klik op een bestand om het te openen in de CodeMirror editor
|
||||
- Klik op een **bestand** om het te openen in de CodeMirror editor
|
||||
- Klik op een **map** (mapnaam) om de map te selecteren — rechts verschijnt het **map detail paneel**
|
||||
- De chevron (▶/▼) naast een map klapt deze in/uit zonder de map te selecteren
|
||||
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
||||
- Per map staan actie-knoppen: nieuw bestand, nieuwe map, hernoemen, verwijderen
|
||||
- Per map staan actie-knoppen: nieuw bestand (plus-icoon), nieuwe map (map-plus-icoon) — deze opereren op de geselecteerde map
|
||||
- Per bestand staan actie-knoppen: hernoemen/verplaatsen (potlood), verwijderen (prullenbak) — verschijnen bij hover
|
||||
|
||||
### Bestanden verslepen (drag-and-drop)
|
||||
- Sleep een bestand of map naar een andere map om het te verplaatsen
|
||||
- Tijdens het slepen worden geldige doel-mappen highlight getoond
|
||||
- De verplaatsing gebeurt via een AJAX-call — de pagina herlaadt automatisch na succes
|
||||
- Beveiliging: een map kan niet naar zichzelf of naar een submap verplaatst worden
|
||||
- Path-traversal bescherming: alle paden blijven binnen de content map
|
||||
|
||||
### Bewerkbare bestandstypen
|
||||
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent met de bestaande content-edit pagina.
|
||||
|
||||
Alle types ondersteunen `---` frontmatter (layout, created, edited, plugins). De CMS `parseMetadata()` haalt de frontmatter eruit vóór verwerking. Bij PHP bestanden wordt de frontmatter uit de output verwijderd.
|
||||
|
||||
#### PHP content bestanden
|
||||
PHP bestanden kunnen op twee manieren content leveren:
|
||||
1. **Echo** (output buffering): alles wat `echo`d of outside `<?php` staat wordt als content gebruikt
|
||||
2. **Return** (callback stijl): `return '<h1>Hallo</h1>';` — de geretourneerde string wordt als content gebruikt
|
||||
|
||||
In PHP bestanden is de `ContentAPI` beschikbaar via `$api`:
|
||||
```php
|
||||
<?php
|
||||
$menu = $api->getMenu();
|
||||
$pages = $api->getAllPages();
|
||||
$config = $api->getConfig('site_title');
|
||||
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
||||
```
|
||||
|
||||
#### Frontend URL's en bestandsnamen
|
||||
De CMS zoekt bestanden in volgorde: `.md` → `.php` → `.html`. Als bestanden dezelfde naam hebben (bijv. `test.md` en `test.php`), opent `/nl/test` altijd `test.md`. Om een specifiek bestandstype te openen, voeg de extensie toe aan de URL:
|
||||
- `/nl/test` → opent `test.md` (of `.php` / `.html` als `.md` niet bestaat)
|
||||
- `/nl/test.php` → opent `test.php` (expliciete extensie)
|
||||
- `/nl/test.html` → opent `test.html` (expliciete extensie)
|
||||
|
||||
De preview link in de editor gebruikt automatisch de extensie voor niet-`.md` bestanden.
|
||||
|
||||
### Image-grootte instellen (Markdown)
|
||||
In de markdown editor-toolbar staat een "Afbeelding grootte" knop (expand-icoon):
|
||||
1. Selecteer een afbeelding in de editor in markdown formaat ``
|
||||
@@ -37,52 +66,53 @@ Bestaande `{:width=...}` waarden worden in de prompts getoond. De grootte-attrib
|
||||
Images in `content/` worden via de `/-media/` endpoint geserveerd (content/ staat buiten de webroot). De markdown syntax `` met lokale URLs (relatief zoals `image.jpg`, of absoluut zoals `/content/_images/image.jpg`) wordt automatisch herschreven naar `/-media/...`. Externe URLs (`https://...`) worden ongewijzigd gelaten.
|
||||
|
||||
### Nieuw bestand aanmaken
|
||||
- Klik op **Nieuw bestand** (bovenaan of per map in de zijbalk)
|
||||
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||
- Klik op **Nieuw bestand** (in het map-detail paneel of bovenaan)
|
||||
- Geef een pad op binnen content (bijv. `nl.pagina` of `blog/nl.post`)
|
||||
- Kies het bestandstype (Markdown/PHP/HTML)
|
||||
- Submappen worden automatisch aangemaakt
|
||||
- Frontmatter met `layout`, `author_name`, `author_email`, `created` wordt automatisch gegenereerd
|
||||
- Frontmatter met `layout`, `created`, `edited` wordt automatisch gegenereerd
|
||||
- Het nieuwe bestand wordt aangemaakt in de geselecteerde map
|
||||
|
||||
### Bestand uploaden
|
||||
- Klik op **Upload** om bestanden naar `content/` te uploaden
|
||||
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||
- Klik op **Upload** (in het map-detail paneel of bovenaan) om bestanden naar de geselecteerde map te uploaden
|
||||
- Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
|
||||
- Path-traversal bescherming: doelmap moet binnen `content/` blijven
|
||||
- Na de upload keer je terug naar de geselecteerde map
|
||||
|
||||
### Bestand verplaatsen / verwijderen
|
||||
- In de bestandsboom heeft elk bestand een verplaats-knop (pijlen-icoon) en een verwijder-knop (prullenbak)
|
||||
- **Verplaatsen**: kies een doelmap uit de dropdown met alle mappen in content
|
||||
- **Verwijderen**: met bevestiging
|
||||
### Bestand hernoemen / verwijderen
|
||||
- Open het bestand in de editor (klik op de bestandsnaam in de zijbalk)
|
||||
- **Hernoemen**: bewerk het **Bestandsnaam** veld rechts boven de editor en klik op Opslaan — het bestand wordt direct hernoemd
|
||||
- **Verwijderen**: klik op de verwijder-knop (prullenbak) naast het bestand in de zijbalk, met bevestiging
|
||||
|
||||
### Mappen beheer
|
||||
- **Nieuwe map**: per map in de zijbalk of bovenaan
|
||||
- **Map hernoemen**: potlood-icoon per map
|
||||
- **Map verwijderen**: prullenbak-icoon per map (alleen lege mappen)
|
||||
- Klik op een map in de zijbalk om het **map-detail paneel** rechts te openen
|
||||
- **Map hernoemen**: bewerk het **Mapnaam** veld in het map-detail paneel en klik op Opslaan
|
||||
- **Nieuw bestand / Nieuwe map / Uploaden in deze map**: gebruik de knoppen in het map-detail paneel — ze opereren op de geselecteerde map
|
||||
- **Map verwijderen**: verwijder-knop in het map-detail paneel (alleen lege mappen)
|
||||
- De content-root is ook selecteerbaar (klik op "content" bovenaan de boom) — toont het map-detail paneel voor de root (hernoemen uitgeschakeld)
|
||||
|
||||
### Layout selectie
|
||||
Op de editor-pagina kun je de layout kiezen uit de layouts gedefinieerd in `theme.json` (template mapping). De geselecteerde layout wordt opgeslagen in de frontmatter `layout:` key.
|
||||
|
||||
### Plugins op pagina's
|
||||
- Content plugins (uit `plugin.json` met `type: "content"`) verschijnen in de plugin selectie
|
||||
- Kies welke plugins op de pagina draaien
|
||||
- Alleen actieve **content plugins** (uit `plugin.json` met `type: "content"`) verschijnen in de plugin multiselect; system plugins (zoals Statistics/Logs/Dashboard) zijn uitgesloten
|
||||
- Kies via de multiselect welke plugins op de pagina draaien (gebruik Ctrl/Cmd+klik voor meerdere)
|
||||
- De plugin selectie wordt opgeslagen in de frontmatter `plugins:` key
|
||||
|
||||
### Git / Backup integratie (Fase 5)
|
||||
Bovenaan de content-editor staan backup- en git-acties:
|
||||
- **Git init**: initialiseert een git repository in `content/` (als er nog geen is)
|
||||
- **Commit**: committed alle niet-committed wijzigingen (alleen als er een git repo is en er wijzigingen zijn)
|
||||
- **Backup**: link naar de backup-pagina (`/admin/content-backup`) voor ZIP backup/restore
|
||||
- De git status badge toont de huidige branch, of er niet-committed wijzigingen zijn, en de laatste commit
|
||||
### Backup integratie
|
||||
Bovenaan de content-editor staat een **Backup** knop die naar de backup-pagina (`/admin/content-backup`) leidt voor ZIP backup/restore en (indien beschikbaar) git versiebeheer. Git integratie in de editor-zijbalk is verwijderd; git wordt later een systeem plugin.
|
||||
|
||||
### Frontmatter
|
||||
|
||||
De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin selectie:
|
||||
De editor toont de **Aangemaakt** en **Bewerkt** timestamps (read-only, automatisch bijgewerkt bij elke opslag). De `edited:` waarde wordt bij elke save vernieuwd.
|
||||
|
||||
```markdown
|
||||
---
|
||||
layout: left_sidebar
|
||||
author_name: Admin
|
||||
author_email: admin@example.com
|
||||
created: 2026-08-19 10:30:25
|
||||
edited: 2026-08-20 14:22:01
|
||||
plugins: HTMLBlock, Navigation
|
||||
---
|
||||
|
||||
@@ -91,24 +121,6 @@ plugins: HTMLBlock, Navigation
|
||||
Content...
|
||||
```
|
||||
|
||||
## Lijst weergave (content)
|
||||
|
||||
### Bestanden beheren
|
||||
|
||||
- **Nieuwe map** — Mappen structuur aanmaken
|
||||
- **Nieuw bestand** — Pagina aanmaken (`.md`, `.php`, `.html`)
|
||||
- **Bewerken** — Bestaande content wijzigen in CodeMirror editor
|
||||
- **Hernoemen** — Bestands- of mapnamen aanpassen
|
||||
- **Verplaatsen** — Content verplaatsen naar andere map
|
||||
- **Verwijderen** — Content verwijderen
|
||||
- **Map hernoemen** — Map naam wijzigen
|
||||
|
||||
### Editor (content-edit)
|
||||
|
||||
- **CodeMirror** met syntax highlighting (Markdown, PHP, HTML)
|
||||
- **Toolbar** voor snel Markdown invoeren
|
||||
- **Sneltoetsen**: Ctrl+S (opslaan), Ctrl+N (nieuw)
|
||||
|
||||
## Frontmatter
|
||||
|
||||
De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin selectie:
|
||||
@@ -116,9 +128,9 @@ De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin sel
|
||||
```markdown
|
||||
---
|
||||
layout: left_sidebar
|
||||
plugins:
|
||||
- HTMLBlock
|
||||
- Navigation
|
||||
created: 2026-08-19 10:30:25
|
||||
edited: 2026-08-20 14:22:01
|
||||
plugins: HTMLBlock, Navigation
|
||||
---
|
||||
|
||||
# Pagina titel
|
||||
|
||||
@@ -27,4 +27,45 @@ Alleen actieve plugins worden getoond in de admin sidebar.
|
||||
3. Bewerk het PHP-bestand
|
||||
4. Activeer de plugin
|
||||
|
||||
Nieuwe plugins zijn standaard content-plugins. Wil je een systeem-plugin maken, voeg dan `"type": "system"` toe aan `plugin.json`.
|
||||
Nieuwe plugins zijn standaard content-plugins. Wil je een systeem-plugin maken, voeg dan `"type": "system"` toe aan `plugin.json`.
|
||||
|
||||
## Plugin editor
|
||||
|
||||
Via **Bewerken** (potlood-icoon) in het plugin-overzicht open je de plugin-editor (`/admin/plugins-edit?plugin=<naam>`). Deze werkt hetzelfde als de content- en thema-editors.
|
||||
|
||||
### Bestandsbrowser zijbalk
|
||||
- Toont de geneste bestandsboom van de plugin, met een klikbare **plugin** root bovenaan
|
||||
- Klik op een bestand om het te openen in de CodeMirror editor
|
||||
- Klik op een map om het **map detail paneel** rechts te openen (nieuw bestand, nieuwe map, uploaden, verwijderen)
|
||||
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
||||
- Per map staan actie-knoppen: nieuw bestand, nieuwe map — opereren op de geselecteerde map
|
||||
- Per bestand staan actie-knoppen: hernoemen/verplaatsen (potlood), verwijderen (prullenbak) — verschijnen bij hover
|
||||
|
||||
### Bestanden verslepen (drag-and-drop)
|
||||
- Sleep een bestand of map naar een andere map om het te verplaatsen
|
||||
- Tijdens het slepen worden geldige doel-mappen highlight getoond
|
||||
- De verplaatsing gebeurt via een AJAX-call — de pagina herlaadt automatisch na succes
|
||||
- Beschermde plugins (zoals Navigation) kunnen niet bewerkt/verplaatst worden
|
||||
|
||||
### Bewerkbare bestandstypen
|
||||
`.php`, `.json`, `.md`, `.html`, `.css`, `.scss`, `.js`
|
||||
|
||||
### Nieuw bestand / nieuwe map aanmaken
|
||||
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||
- Klik op **Nieuw bestand** of **Nieuwe map** (in het map-detail paneel of bovenaan)
|
||||
- Bij nieuw bestand: geef een pad op binnen de plugin (bijv. `helper.php` of `assets/css/extra.css`)
|
||||
- Submappen worden automatisch aangemaakt
|
||||
|
||||
### Bestand verplaatsen / verwijderen
|
||||
- **Verplaatsen**: sleep het bestand naar een andere map (drag-and-drop), of klik op het potlood-icoon
|
||||
- **Verwijderen**: klik op de prullenbak-knop naast het bestand in de zijbalk, met bevestiging
|
||||
|
||||
### Mappen beheer
|
||||
- Klik op een map in de zijbalk om het **map-detail paneel** rechts te openen
|
||||
- **Nieuw bestand / Nieuwe map / Uploaden in deze map**: gebruik de knoppen in het map-detail paneel
|
||||
- **Map verwijderen**: verwijder-knop in het map-detail paneel (alleen lege mappen)
|
||||
|
||||
### Beveiliging
|
||||
- Beschermde plugins (Navigation) kunnen niet bewerkt worden
|
||||
- Alle acties vereisen CSRF token
|
||||
- Path-traversal bescherming via `realpath()` + prefix-check op de plugin-map
|
||||
@@ -21,17 +21,27 @@ In het thema-overzicht zie je per thema:
|
||||
Via **Bewerken** (potlood-icoon) in het thema-overzicht open je de thema-editor (`/admin/theme-edit?theme=<naam>`). Deze werkt hetzelfde als de plugin-editor:
|
||||
|
||||
### Bestandsbrowser zijbalk
|
||||
- Toont de geneste bestandsboom van het thema
|
||||
- Toont de geneste bestandsboom van het thema, met een klikbare **thema** root bovenaan
|
||||
- `assets/css_compiled/` wordt verborgen (runtime artefact, read-only)
|
||||
- Klik op een bestand om het te openen in de CodeMirror editor
|
||||
- Klik op een map om het **map detail paneel** rechts te openen (nieuw bestand, nieuwe map, uploaden, verwijderen)
|
||||
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
||||
- Per map staan actie-knoppen: nieuw bestand, nieuwe map — opereren op de geselecteerde map
|
||||
- Per bestand staan actie-knoppen: hernoemen/verplaatsen (potlood), verwijderen (prullenbak) — verschijnen bij hover
|
||||
|
||||
### Bestanden verslepen (drag-and-drop)
|
||||
- Sleep een bestand of map naar een andere map om het te verplaatsen
|
||||
- Tijdens het slepen worden geldige doel-mappen highlight getoond
|
||||
- De verplaatsing gebeurt via een AJAX-call — de pagina herlaadt automatisch na succes
|
||||
- `theme.json` kan niet verplaatst worden
|
||||
|
||||
### Bewerkbare bestandstypen
|
||||
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
|
||||
|
||||
### Nieuw bestand aanmaken
|
||||
- Klik op **Nieuw bestand**
|
||||
- Geef een pad op binnen het thema (bijv. `partials/header.twig` of `assets/scss/_variables.scss`)
|
||||
### Nieuw bestand / nieuwe map aanmaken
|
||||
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||
- Klik op **Nieuw bestand** of **Nieuwe map** (in het map-detail paneel of bovenaan)
|
||||
- Bij nieuw bestand: geef een pad op binnen het thema (bijv. `partials/header.twig` of `assets/scss/_variables.scss`)
|
||||
- Submappen worden automatisch aangemaakt
|
||||
- Toegestaan: twig, json, scss, css, js, html, md, php
|
||||
- Een stub wordt automatisch gegenereerd (bijv. `{% extends 'base.twig' %}` voor `.twig`)
|
||||
@@ -42,9 +52,14 @@ Via **Bewerken** (potlood-icoon) in het thema-overzicht open je de thema-editor
|
||||
- Path-traversal bescherming: doelmap moet binnen `assets/` blijven
|
||||
|
||||
### Bestand verplaatsen / verwijderen
|
||||
- In de bestandsboom heeft elk bestand een verplaats-knop (pijlen-icoon) en een verwijder-knop (prullenbak)
|
||||
- **Verplaatsen**: kies een doelmap uit de dropdown met alle mappen in het thema
|
||||
- **Verwijderen**: met bevestiging; `theme.json` kan niet verwijderd worden
|
||||
- **Verplaatsen**: sleep het bestand naar een andere map (drag-and-drop), of klik op het potlood-icoon naast het bestand om naar het verplaats-formulier te gaan
|
||||
- **Verwijderen**: klik op de prullenbak-knop naast het bestand in de zijbalk, met bevestiging; `theme.json` kan niet verwijderd worden
|
||||
|
||||
### Mappen beheer
|
||||
- Klik op een map in de zijbalk om het **map-detail paneel** rechts te openen
|
||||
- **Nieuw bestand / Nieuwe map / Uploaden in deze map**: gebruik de knoppen in het map-detail paneel
|
||||
- **Map verwijderen**: verwijder-knop in het map-detail paneel (alleen lege mappen)
|
||||
- De thema-root is ook selecteerbaar (klik op de thema-naam bovenaan de boom)
|
||||
|
||||
### SCSS compileren vanuit de editor
|
||||
- Bovenaan de editor staat een **SCSS compileren** knop (alleen als `assets/scss/theme.scss` bestaat)
|
||||
@@ -53,6 +68,7 @@ Via **Bewerken** (potlood-icoon) in het thema-overzicht open je de thema-editor
|
||||
|
||||
### Media invoegen in editor
|
||||
- De media-knop in de editor-toolbar opent de media-modal
|
||||
- Media wordt getoond als een collapsible **bestandsboom** (folders inklappen/uitklappen), gelijk aan de content/plugin/theme editors
|
||||
- In thema-context scant deze `themes/<naam>/assets/` (via `/admin/media-list?theme=<naam>`)
|
||||
- Snippet-formaat depends op bestandstype: markdown → ``, html/php → `<img src=...>`, andere → ruwe URL
|
||||
|
||||
|
||||
@@ -1,84 +1,132 @@
|
||||
# Content API
|
||||
|
||||
De Content API is beschikbaar in PHP content bestanden (`.php`) en biedt een veilige, read-only interface tot CMS data.
|
||||
PHP content bestanden (`.php`) draaien binnen het CMS en hebben toegang tot een
|
||||
veilige, read-only API via de variabele `$api`. De output van zo'n bestand wordt
|
||||
automatisch in de actieve Twig layout geplaatst op de plek van `{{ content }}`.
|
||||
|
||||
## Gebruik in PHP content bestanden
|
||||
## Hoe je content doorgeeft aan de Twig template
|
||||
|
||||
Er zijn **twee manieren** om content vanuit een `.php` bestand naar het Twig
|
||||
template te sturen. De CMS kiest automatisch de juiste:
|
||||
|
||||
1. **Echo / print** — alles wat je echoot (of wat buiten `<?php ?>` tags staat)
|
||||
wordt opgevangen en als `{{ content }}` in de layout geplaatst.
|
||||
2. **Return** — als je `return` gebruikt met een string, wordt die string als
|
||||
`{{ content }}` gebruikt (echt output wordt dan genegeerd).
|
||||
|
||||
Voorbeeld met echo:
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
|
||||
// Alle pagina's ophalen
|
||||
$allPages = $api->getAllPages();
|
||||
// Resultaat: ['index' => 'Home', 'over-ons' => 'Over ons', ...]
|
||||
|
||||
// Specifieke pagina ophalen
|
||||
$page = $api->getPage('over-ons');
|
||||
// Resultaat: ['title' => 'Over ons', 'content' => '...', 'path' => 'over-ons', 'layout' => 'full_content', 'metadata' => [...]]
|
||||
|
||||
// Menu structuur ophalen
|
||||
$menu = $api->getMenu();
|
||||
// Resultaat: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
|
||||
|
||||
// Config waarde ophalen (dot notatie)
|
||||
$siteTitle = $api->getConfig('site_title');
|
||||
$searchEnabled = $api->getConfig('features.search_enabled', false);
|
||||
?>
|
||||
<h1>Hallo <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||
<p>Welkom op mijn pagina.</p>
|
||||
```
|
||||
|
||||
## Beschikbare methods
|
||||
Voorbeeld met return:
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
return '<h1>Hallo ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
|
||||
```
|
||||
|
||||
> De frontmatter (`---` blok) wordt door het CMS gestript vóórdat de PHP code
|
||||
> uitgevoerd wordt. De layout key bepaalt welke Twig template de content omringt.
|
||||
|
||||
## Wat kun je níét doen
|
||||
|
||||
- Je kunt **geen** eigen Twig variabelen zetten vanuit een `.php` bestand.
|
||||
PHP content leverde alleen de `{{ content }}` placeholder. Andere template
|
||||
variabelen (`menu`, `breadcrumb`, `page_title`, etc.) worden door het CMS
|
||||
vastgesteld op basis van de frontmatter en config — niet door je PHP code.
|
||||
- Je kunt **geen** van buitenaf een ContentAPI aanroepen; de class wordt alleen
|
||||
binnen `parsePHP()` geïnstantieerd en is nooit via een URL bereikbaar.
|
||||
|
||||
## Beschikbare variabelen in je PHP bestand
|
||||
|
||||
Binnen een `.php` content bestand zijn de volgende variabelen beschikbaar:
|
||||
|
||||
| Variabele | Type | Omschrijving |
|
||||
|-----------|------|--------------|
|
||||
| `$api` | `ContentAPI` | Read-only toegang tot CMS data |
|
||||
| `$pageMetadata` | `array` | De frontmatter metadata van dit bestand |
|
||||
|
||||
## ContentAPI methods
|
||||
|
||||
### Pagina's
|
||||
|
||||
- `getAllPages(): array` - Alle pagina's als `['pad' => 'titel']` pairs
|
||||
- `getPage(string $path): ?array` - Specifieke pagina met `title`, `content`, `path`, `layout`, `metadata`
|
||||
- `pageExists(string $path): bool` - Controleer of een pagina bestaat
|
||||
- `getCurrentPageTitle(): string` - Titel van huidige pagina
|
||||
- `getCurrentPagePath(): string` - Pad van huidige pagina
|
||||
- `isHomepage(): bool` - Of huidige pagina de homepage is
|
||||
- `getAllPages(): array` — Alle content entries (mappen + bestanden) als een list van `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
|
||||
- `getPage(string $path): ?array` — Specifieke pagina; levert `title`, `content`, `path`, `layout`, `metadata`. `$path` mag een extensie bevatten.
|
||||
- `pageExists(string $path): bool` — Controleer of een pagina bestaat
|
||||
- `getCurrentPageTitle(): string` — Titel van de huidige pagina
|
||||
- `getCurrentPagePath(): string` — Pad van de huidige pagina
|
||||
- `isHomepage(): bool` — Of de huidige pagina de homepage is
|
||||
|
||||
### Menu & Navigatie
|
||||
### Menu & navigatie
|
||||
|
||||
- `getMenu(): array` - Hiërarchische menu structuur met `title`, `path`, `children`, `active`
|
||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Bouw een URL voor een pagina
|
||||
- `getMenu(): array` — Hiërarchische menu structuur met `title`, `path`, `children`, `active`
|
||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` — Bouw een URL
|
||||
|
||||
### Configuratie
|
||||
|
||||
- `getConfig(string $key, mixed $default = null): mixed` - Config waarde via dot notatie (bijv. `'features.search_enabled'`)
|
||||
- `getSiteTitle(): string` - Site titel uit config
|
||||
- `getConfig(string $key, mixed $default = null): mixed` — Config waarde via dot notatie (bijv. `'features.search_enabled'`)
|
||||
- `getSiteTitle(): string` — Site titel uit config
|
||||
|
||||
### Taal
|
||||
|
||||
- `getCurrentLanguage(): string` - Huidige taal code (bijv. `'nl'`)
|
||||
- `getAvailableLanguages(): array` - Beschikbare talen (bijv. `['nl', 'en']`)
|
||||
- `t(string $key): string` - Vertaal een language key
|
||||
- `getCurrentLanguage(): string` — Huidige taal code (bijv. `'nl'`)
|
||||
- `getAvailableLanguages(): array` — Beschikbare talen
|
||||
- `t(string $key): string` — Vertaal een language key
|
||||
|
||||
### Zoeken
|
||||
|
||||
- `getSearchResults(): array` - Zoekresultaten (leeg als niet aan het zoeken)
|
||||
- `isSearching(): bool` - Of er momenteel gezocht wordt
|
||||
- `getSearchResults(): array` — Zoekresultaten (leeg als niet aan het zoeken)
|
||||
- `isSearching(): bool` — Of er momenteel gezocht wordt
|
||||
|
||||
## Voorbeeld: Recentste pagina's tonen
|
||||
### Auteur
|
||||
|
||||
- `getPageAuthor(): array` — Auteur metadata uit frontmatter (`author_name`, `author_email`, `created`)
|
||||
|
||||
## Voorbeelden
|
||||
|
||||
### Recente pagina's tonen
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
$pages = $api->getAllPages();
|
||||
$entries = $api->getAllPages();
|
||||
$currentLang = $api->getCurrentLanguage();
|
||||
?>
|
||||
<ul>
|
||||
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
||||
<?php foreach ($entries as $entry): ?>
|
||||
<?php if ($entry['type'] === 'folder') continue; // sla mappen over ?>
|
||||
<li>
|
||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
||||
<?= htmlspecialchars($title) ?>
|
||||
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
||||
<?= htmlspecialchars($entry['title']) ?>
|
||||
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Voorbeeld: Config waarde gebruiken
|
||||
### Config waarde gebruiken
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
if ($api->getConfig('features.search_enabled', false)): ?>
|
||||
@@ -89,9 +137,48 @@ if ($api->getConfig('features.search_enabled', false)): ?>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Dynamische begroeting op basis van taal
|
||||
|
||||
```php
|
||||
---
|
||||
layout: full_content
|
||||
---
|
||||
<?php
|
||||
/** @var ContentAPI $api */
|
||||
$lang = $api->getCurrentLanguage();
|
||||
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
|
||||
?>
|
||||
<h1><?= $greeting ?> op <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||
```
|
||||
|
||||
## Layout kiezen
|
||||
|
||||
De frontmatter `layout` key bepaalt welke Twig template je content omringt. De
|
||||
beschikbare layouts staan in `themes/<actief-thema>/theme.json` onder de
|
||||
`template` sectie. Bijvoorbeeld:
|
||||
|
||||
```yaml
|
||||
---
|
||||
layout: sidebar-content
|
||||
---
|
||||
```
|
||||
|
||||
Als je geen layout opgeeft, wordt `config.default_template` gebruikt, met als
|
||||
fallback `full_content`. De content komt altijd terecht op de `{{ content }}`
|
||||
plek in die template.
|
||||
|
||||
## Veiligheid
|
||||
|
||||
- Gebruik altijd `htmlspecialchars()` voor output van user-content of API data.
|
||||
- PHP content bestanden hebben toegang tot het bestandssysteem van de server —
|
||||
wees voorzichtig met `include`, `require` of `file_get_contents` op externe
|
||||
paden. Blijf binnen de `content/` map.
|
||||
- De ContentAPI is read-only; je kunt er geen bestanden of config mee wijzigen.
|
||||
|
||||
## CMSAPI (voor plugins)
|
||||
|
||||
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare methods:
|
||||
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare
|
||||
methods:
|
||||
|
||||
```php
|
||||
$this->api->getCurrentPageTitle();
|
||||
@@ -110,10 +197,10 @@ $this->api->translate('home');
|
||||
|
||||
Daarnaast heeft de CMSAPI:
|
||||
|
||||
- `getCurrentPage(): array` - Volledige pagina data
|
||||
- `getCurrentPageUrl(): string` - URL van huidige pagina
|
||||
- `getCurrentPageFileInfo(): ?array` - Bestandsinfo (created, modified)
|
||||
- `getBreadcrumb(): string` - Breadcrumb HTML
|
||||
- `executePhpFile(string $filePath): string` - Voer PHP bestand uit en vang output op
|
||||
- `getFileContent(string $filePath): string` - Haal content uit PHP/HTML/Markdown bestand
|
||||
- `contentFileExists(string $filename): bool` - Controleer of bestand bestaat in content map
|
||||
- `getCurrentPage(): array` — Volledige pagina data
|
||||
- `getCurrentPageUrl(): string` — URL van huidige pagina
|
||||
- `getCurrentPageFileInfo(): ?array` — Bestandsinfo (created, modified)
|
||||
- `getBreadcrumb(): string` — Breadcrumb HTML
|
||||
- `executePhpFile(string $filePath): string` — Voer PHP bestand uit en vang output op
|
||||
- `getFileContent(string $filePath): string` — Haal content uit PHP/HTML/Markdown bestand
|
||||
- `contentFileExists(string $filename): bool` — Controleer of bestand bestaat in content map
|
||||
@@ -94,7 +94,7 @@ return [
|
||||
'content_files' => 'Content-Dateien',
|
||||
'content_no_files' => 'Keine Dateien gefunden.',
|
||||
'content_not_editable' => 'Dieser Dateityp kann im Editor nicht bearbeitet werden.',
|
||||
'content_select_file' => 'Wählen Sie eine Datei aus der Seitenleiste zum Bearbeiten.',
|
||||
'content_select_file' => 'Wählen Sie eine Datei oder einen Ordner aus der Seitenleiste.',
|
||||
'content_file_path' => 'Dateipfad innerhalb von content',
|
||||
'content_file_path_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche, Bindestriche und Schrägstriche. Unterordner werden automatisch erstellt. Die Erweiterung wird unten ausgewählt.',
|
||||
'content_upload_help' => 'Dateien werden in content/ hochgeladen. Erlaubt: Bilder, Video, Audio, PDF, ZIP, Office-Dokumente, CSS, SCSS, JS, JSON, HTML, MD.',
|
||||
@@ -120,6 +120,11 @@ return [
|
||||
'no_filter_results' => 'Keine Ergebnisse für diesen Filter.',
|
||||
'new_folder_title' => 'Neuen Ordner erstellen',
|
||||
'folder_name' => 'Ordnername',
|
||||
'folder_name_placeholder' => 'Ordnername',
|
||||
'folder_rename_help' => 'Bearbeiten Sie den Ordnernamen und klicken Sie auf Speichern zum Umbenennen.',
|
||||
'files' => 'Dateien',
|
||||
'subfolders' => 'Unterordner',
|
||||
'expand_collapse' => 'Ausklappen/Einklappen',
|
||||
'name_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche.',
|
||||
|
||||
// Content edit / new
|
||||
@@ -128,6 +133,8 @@ return [
|
||||
'visible_plugins' => 'Sichtbare Plugins',
|
||||
'new_page' => 'Neue Seite',
|
||||
'file_type' => 'Dateityp',
|
||||
'created' => 'Erstellt',
|
||||
'edited' => 'Bearbeitet',
|
||||
|
||||
// Content dir form
|
||||
'rename_folder' => 'Ordner umbenennen',
|
||||
|
||||
@@ -94,7 +94,7 @@ return [
|
||||
'content_files' => 'Content files',
|
||||
'content_no_files' => 'No files found.',
|
||||
'content_not_editable' => 'This file type cannot be edited in the editor.',
|
||||
'content_select_file' => 'Select a file from the sidebar to edit.',
|
||||
'content_select_file' => 'Select a file or folder from the sidebar.',
|
||||
'content_file_path' => 'File path within content',
|
||||
'content_file_path_help' => 'Only letters, numbers, dots, underscores, dashes and slashes. Subfolders are created automatically. The extension is chosen below.',
|
||||
'content_upload_help' => 'Files are uploaded to content/. Allowed: images, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.',
|
||||
@@ -120,6 +120,11 @@ return [
|
||||
'no_filter_results' => 'No results for this filter.',
|
||||
'new_folder_title' => 'Create new folder',
|
||||
'folder_name' => 'Folder name',
|
||||
'folder_name_placeholder' => 'folder name',
|
||||
'folder_rename_help' => 'Edit the folder name and click save to rename it.',
|
||||
'files' => 'files',
|
||||
'subfolders' => 'subfolders',
|
||||
'expand_collapse' => 'Expand/collapse',
|
||||
'name_help' => 'Only letters, numbers, dots, underscores and dashes.',
|
||||
|
||||
// Content edit / new
|
||||
@@ -326,6 +331,7 @@ return [
|
||||
'cannot_change_own_role' => 'You cannot change your own role.',
|
||||
'cannot_change_password_role' => 'Changing passwords is not allowed in this role.',
|
||||
'created' => 'Created',
|
||||
'edited' => 'Edited',
|
||||
|
||||
// Plugin config
|
||||
'plugin_settings' => 'Settings',
|
||||
|
||||
@@ -96,7 +96,7 @@ return [
|
||||
'content_files' => 'Content bestanden',
|
||||
'content_no_files' => 'Geen bestanden gevonden.',
|
||||
'content_not_editable' => 'Dit bestandstype kan niet in de editor bewerkt worden.',
|
||||
'content_select_file' => 'Selecteer een bestand uit de zijbalk om te bewerken.',
|
||||
'content_select_file' => 'Selecteer een bestand of map uit de zijbalk.',
|
||||
'content_file_path' => 'Bestandspad binnen content',
|
||||
'content_file_path_help' => 'Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. De extensie wordt hieronder gekozen.',
|
||||
'content_upload_help' => 'Bestanden worden geüpload naar content/. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.',
|
||||
@@ -122,6 +122,11 @@ return [
|
||||
'no_filter_results' => 'Geen resultaten voor deze filter.',
|
||||
'new_folder_title' => 'Nieuwe map aanmaken',
|
||||
'folder_name' => 'Mapnaam',
|
||||
'folder_name_placeholder' => 'mapnaam',
|
||||
'folder_rename_help' => 'Bewerk de mapnaam en klik op opslaan om te hernoemen.',
|
||||
'files' => 'bestanden',
|
||||
'subfolders' => 'submappen',
|
||||
'expand_collapse' => 'Uitklappen/inklappen',
|
||||
'name_help' => 'Alleen letters, cijfers, punten, underscores en streepjes.',
|
||||
|
||||
// Content edit / new
|
||||
@@ -328,6 +333,7 @@ return [
|
||||
'cannot_change_own_role' => 'Je kunt je eigen rol niet wijzigen.',
|
||||
'cannot_change_password_role' => 'Wachtwoord wijzigen is niet toegestaan in deze rol.',
|
||||
'created' => 'Aangemaakt',
|
||||
'edited' => 'Bewerkt',
|
||||
|
||||
// Plugin config
|
||||
'plugin_settings' => 'Instellingen',
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dashboard plugin: toont CMS-, content- en plugin-overzicht in het beheer.
|
||||
*
|
||||
* Systeem-plugin die een overzichtspagina biedt met site-informatie,
|
||||
* content-statistieken en een lijst van geïnstalleerde plugins.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class Dashboard
|
||||
{
|
||||
/**
|
||||
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginAPIInterface|null
|
||||
*/
|
||||
private ?PluginAPIInterface $api = null;
|
||||
|
||||
/**
|
||||
* Stelt de Plugin-API instantie in.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setAPI(PluginAPIInterface $api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de plugin-configuratie terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> Plugin-configuratie met title, viewable en type.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -18,6 +45,12 @@ class Dashboard
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de admin-menu-items voor deze plugin.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<int, array<string, mixed>> Lijst met admin-menu-item configuraties.
|
||||
*/
|
||||
public function getAdminMenu(): array
|
||||
{
|
||||
return [
|
||||
@@ -33,6 +66,16 @@ class Dashboard
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwerkt een admin-route en toont het dashboard.
|
||||
*
|
||||
* Geeft een HTML-weergave van site-informatie, content-statistieken
|
||||
* en een plugin-overzicht.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $action De uit te voeren actie.
|
||||
* @return string|null De HTML-uitvoer van het dashboard, of null.
|
||||
*/
|
||||
public function handleAdminRoute(string $action): ?string
|
||||
{
|
||||
$siteConfig = $this->getSiteConfig();
|
||||
@@ -147,6 +190,12 @@ class Dashboard
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de site-configuratie op via de AdminPluginAPI.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> De site-configuratie, of een lege array.
|
||||
*/
|
||||
private function getSiteConfig(): array
|
||||
{
|
||||
if ($this->api === null) {
|
||||
@@ -159,6 +208,14 @@ class Dashboard
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bepaalt het type (system of content) van een plugin door het hoofdbestand te lezen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $pluginDir Pad naar de plugin-map.
|
||||
* @param string $pluginName Naam van de plugin.
|
||||
* @return string Het plugin-type ('system' of 'content').
|
||||
*/
|
||||
private function getPluginType(string $pluginDir, string $pluginName): string
|
||||
{
|
||||
$pluginFile = $pluginDir . '/' . $pluginName . '.php';
|
||||
@@ -173,6 +230,16 @@ class Dashboard
|
||||
return 'content';
|
||||
}
|
||||
|
||||
/**
|
||||
* Telt het aantal bestanden in een map, eventueel gefilterd op extensie.
|
||||
*
|
||||
* Slaat verborgen en met '-' voorafgegane bestanden en mappen over.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $dir Pad naar de map die doorzocht moet worden.
|
||||
* @param string[] $extensions Optionele lijst van toegestane extensies.
|
||||
* @return int Het aantal gevonden bestanden.
|
||||
*/
|
||||
private function countFiles(string $dir, array $extensions = []): int
|
||||
{
|
||||
if (!is_dir($dir)) return 0;
|
||||
@@ -197,6 +264,15 @@ class Dashboard
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telt het aantal submappen in een map, recursief.
|
||||
*
|
||||
* Slaat verborgen en met '-' voorafgegane mappen over.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $dir Pad naar de map die doorzocht moet worden.
|
||||
* @return int Het aantal gevonden submappen.
|
||||
*/
|
||||
private function countDirs(string $dir): int
|
||||
{
|
||||
if (!is_dir($dir)) return 0;
|
||||
@@ -215,6 +291,13 @@ class Dashboard
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berekent de totale bestandsgrootte in bytes van alle bestanden in een map.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $dir Pad naar de map die doorzocht moet worden.
|
||||
* @return int De totale grootte in bytes.
|
||||
*/
|
||||
private function dirSize(string $dir): int
|
||||
{
|
||||
if (!is_dir($dir)) return 0;
|
||||
@@ -230,6 +313,13 @@ class Dashboard
|
||||
return $size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formateert een byte-grootte naar een leesbare string met eenheid.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param int $bytes Het aantal bytes.
|
||||
* @return string De geformatteerde grootte met eenheid (bijv. '1.5 MB').
|
||||
*/
|
||||
private function formatSize(int $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) return '0 B';
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GeoIPInfo plugin: toont geografische informatie op basis van IP-adres.
|
||||
*
|
||||
* Systeem-plugin die een admin-pagina biedt met het IP-adres van de bezoeker
|
||||
* en het bijbehorende land via de GeoIP-dienst.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class GeoIPInfo
|
||||
{
|
||||
/**
|
||||
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginAPIInterface|null
|
||||
*/
|
||||
private ?PluginAPIInterface $api = null;
|
||||
|
||||
/**
|
||||
* Stelt de Plugin-API instantie in.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setAPI(PluginAPIInterface $api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de plugin-configuratie terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> Plugin-configuratie met title en type.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -18,7 +45,10 @@ class GeoIPInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Register admin menu items.
|
||||
* Geeft de admin-menu-items voor deze plugin terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<int, array<string, mixed>> Lijst met admin-menu-item configuraties.
|
||||
*/
|
||||
public function getAdminMenu(): array
|
||||
{
|
||||
@@ -34,7 +64,10 @@ class GeoIPInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Register admin routes this plugin handles.
|
||||
* Geeft de admin-routes terug die deze plugin afhandelt.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<int, string> Lijst met admin-route namen.
|
||||
*/
|
||||
public function getAdminRoutes(): array
|
||||
{
|
||||
@@ -42,7 +75,13 @@ class GeoIPInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle admin route — render the admin page.
|
||||
* Verwerkt een admin-route en toont de GeoIP-info-pagina.
|
||||
*
|
||||
* Bepaalt het IP-adres van de bezoeker en toont het land en de vlag.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $route De af te handelen admin-route.
|
||||
* @return void
|
||||
*/
|
||||
public function handleAdminRoute(string $route): void
|
||||
{
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* HTMLBlock plugin: toont context-afhankelijke HTML-blokken in de zijbalk.
|
||||
*
|
||||
* Content-plugin die informatie over de huidige pagina toont,
|
||||
* inclusief taal, homepage-status, bestandsinformatie en snelle navigatie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class HTMLBlock
|
||||
{
|
||||
/**
|
||||
* Plugin-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginAPIInterface|null
|
||||
*/
|
||||
private ?PluginAPIInterface $api = null;
|
||||
|
||||
/**
|
||||
* Initialiseert de plugin met standaardconfiguratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = [
|
||||
@@ -13,11 +39,27 @@ class HTMLBlock
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stelt de Plugin-API instantie in.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setAPI(PluginAPIInterface $api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert de HTML-inhoud voor de zijbalk.
|
||||
*
|
||||
* Toont informatie over de huidige pagina, taal, homepage-status,
|
||||
* bestandsinformatie en een snelle navigatielijst.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return string De opgebouwde HTML voor de zijbalk.
|
||||
*/
|
||||
public function getSidebarContent(): string
|
||||
{
|
||||
// Content plugin: translations follow the current content language.
|
||||
@@ -89,11 +131,24 @@ class HTMLBlock
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de plugin-configuratie terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> Plugin-configuratie.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stelt de plugin-configuratie in door deze te mergen met standaardwaarden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param array<string, mixed> $config De configuratie die samengevoegd moet worden.
|
||||
* @return void
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
|
||||
+50
-2
@@ -1,14 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Logs plugin: toont admin- en request-logs in het beheer.
|
||||
*
|
||||
* Systeem-plugin die logbestanden uitleest en in een tabel toont,
|
||||
* met tabbladen voor admin-logs en request-logs.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class Logs
|
||||
{
|
||||
/**
|
||||
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginAPIInterface|null
|
||||
*/
|
||||
private ?PluginAPIInterface $api = null;
|
||||
|
||||
/**
|
||||
* Stelt de Plugin-API instantie in.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setAPI(PluginAPIInterface $api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de plugin-configuratie terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> Plugin-configuratie met title, viewable en type.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -18,6 +45,12 @@ class Logs
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de admin-menu-items voor deze plugin terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<int, array<string, mixed>> Lijst met admin-menu-item configuraties.
|
||||
*/
|
||||
public function getAdminMenu(): array
|
||||
{
|
||||
$config = $this->getPluginConfig();
|
||||
@@ -38,7 +71,12 @@ class Logs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this plugin's runtime config (defaults + overrides).
|
||||
* Haalt de runtime-configuratie van deze plugin op (standaardwaarden plus overrides).
|
||||
*
|
||||
* Leest standaardwaarden uit plugin.json en overschrijft deze met config.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> De samengevoegde plugin-configuratie.
|
||||
*/
|
||||
private function getPluginConfig(): array
|
||||
{
|
||||
@@ -64,6 +102,16 @@ class Logs
|
||||
return array_merge($defaults, $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwerkt een admin-route en toont de logs-pagina.
|
||||
*
|
||||
* Leest de geselecteerde log, parset de regels en toont deze in een tabel
|
||||
* met tabbladen voor admin- en request-logs.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $action De uit te voeren actie.
|
||||
* @return string|null De HTML-uitvoer van de logs-pagina, of null.
|
||||
*/
|
||||
public function handleAdminRoute(string $action): ?string
|
||||
{
|
||||
$config = $this->getPluginConfig();
|
||||
@@ -78,7 +126,7 @@ class Logs
|
||||
};
|
||||
|
||||
$tab = $_GET['tab'] ?? 'admin';
|
||||
$logDir = dirname(__DIR__, 2) . '/admin/storage/logs';
|
||||
$logDir = ($this->api ? $this->api->getProjectRoot() : dirname(__DIR__, 2)) . '/admin/storage/logs';
|
||||
$logFile = $tab === 'requests' ? $logDir . '/requests.log' : $logDir . '/admin.log';
|
||||
|
||||
$logs = [];
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Navigation plugin: bouwt zijbalk-navigatie voor content en guide.
|
||||
*
|
||||
* Content-plugin die de navigatie opbouwt op basis van de mappenstructuur,
|
||||
* met ondersteuning voor guide-pagina's en reguliere content-pagina's.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class Navigation
|
||||
{
|
||||
/**
|
||||
* Plugin-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginAPIInterface|null
|
||||
*/
|
||||
private ?PluginAPIInterface $api = null;
|
||||
|
||||
/**
|
||||
* Initialiseert de plugin met standaardconfiguratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = [
|
||||
@@ -14,23 +40,46 @@ class Navigation
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stelt de Plugin-API instantie in.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setAPI(PluginAPIInterface $api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de plugin-configuratie terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> Plugin-configuratie.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stelt de plugin-configuratie in door deze te mergen met standaardwaarden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param array<string, mixed> $config De configuratie die samengevoegd moet worden.
|
||||
* @return void
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to this plugin's CSS file (relative to web root).
|
||||
* Geeft het pad naar het CSS-bestand van deze plugin (relatief ten opzichte van de webroot).
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return string Het CSS-URL-pad.
|
||||
*/
|
||||
public function getCssUrl(): string
|
||||
{
|
||||
@@ -38,8 +87,13 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sidebar navigation.
|
||||
* Detects whether we're on a guide page or content page and builds nav accordingly.
|
||||
* Genereert de zijbalk-navigatie.
|
||||
*
|
||||
* Detecteert of het om een guide-pagina of een content-pagina gaat en
|
||||
* bouwt de navigatie dienovereenkomstig op.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return string De HTML-navigatie, of een lege string in admin-context.
|
||||
*/
|
||||
public function getSidebarContent(): string
|
||||
{
|
||||
@@ -61,11 +115,17 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Build navigation for the guide section.
|
||||
* Bouwt de navigatie voor de guide-sectie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $lang De huidige taalcode.
|
||||
* @param string $currentPage De huidige pagina-pad.
|
||||
* @param bool $isAdmin Of dit in admin-context is.
|
||||
* @return string De opgebouwde HTML-navigatie, of een lege string.
|
||||
*/
|
||||
private function buildGuideNav(string $lang, string $currentPage, bool $isAdmin): string
|
||||
{
|
||||
$guideRoot = dirname(__DIR__, 2) . '/guide/' . $lang;
|
||||
$guideRoot = ($this->api ? $this->api->getProjectRoot() : dirname(__DIR__, 2)) . '/guide/' . $lang;
|
||||
if (!is_dir($guideRoot)) {
|
||||
return '';
|
||||
}
|
||||
@@ -79,11 +139,16 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Build navigation for the content section.
|
||||
* Bouwt de navigatie voor de content-sectie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $lang De huidige taalcode.
|
||||
* @param string $currentPage De huidige pagina-pad.
|
||||
* @return string De opgebouwde HTML-navigatie, of een lege string.
|
||||
*/
|
||||
private function buildContentNav(string $lang, string $currentPage): string
|
||||
{
|
||||
$contentRoot = dirname(__DIR__, 2) . '/content';
|
||||
$contentRoot = $this->api ? $this->api->getContentDir() : dirname(__DIR__, 2) . '/content';
|
||||
if (!is_dir($contentRoot)) {
|
||||
return '';
|
||||
}
|
||||
@@ -95,15 +160,17 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build navigation from a directory structure.
|
||||
* Bouwt recursief navigatie op vanuit een mapstructuur.
|
||||
*
|
||||
* @param string $dir Current directory
|
||||
* @param string $relPath Relative path from root
|
||||
* @param string $currentPage Current page path
|
||||
* @param bool $isAdmin Whether this is admin context
|
||||
* @param string $mode 'guide' or 'content'
|
||||
* @param string $lang Current language
|
||||
* @param int $depth Nesting depth (0 = top level)
|
||||
* @since 2.6.5
|
||||
* @param string $dir Huidige map.
|
||||
* @param string $relPath Relatief pad vanaf de root.
|
||||
* @param string $currentPage Huidige pagina-pad.
|
||||
* @param bool $isAdmin Of dit in admin-context is.
|
||||
* @param string $mode Modus: 'guide' of 'content'.
|
||||
* @param string $lang Huidige taalcode.
|
||||
* @param int $depth Nesting-diepte (0 = topniveau). Standaard 0.
|
||||
* @return string De opgebouwde HTML-navigatie.
|
||||
*/
|
||||
private function buildNav(string $dir, string $relPath, string $currentPage, bool $isAdmin, string $mode, string $lang, int $depth = 0): string
|
||||
{
|
||||
@@ -188,7 +255,14 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL for a page.
|
||||
* Bouwt de URL voor een pagina-referentie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $pageRef De pagina-referentie.
|
||||
* @param string $lang De huidige taalcode.
|
||||
* @param bool $isAdmin Of dit in admin-context is.
|
||||
* @param string $mode Modus: 'guide' of 'content'.
|
||||
* @return string De opgebouwde URL.
|
||||
*/
|
||||
private function buildUrl(string $pageRef, string $lang, bool $isAdmin, string $mode): string
|
||||
{
|
||||
@@ -203,7 +277,13 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a nav item is the current page.
|
||||
* Controleert of een navigatie-item de huidige pagina is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $pageRef De pagina-referentie van het item.
|
||||
* @param string $currentPage Het huidige pagina-pad.
|
||||
* @param string $mode Modus: 'guide' of 'content'.
|
||||
* @return bool True als het item actief is, anders false.
|
||||
*/
|
||||
private function isActive(string $pageRef, string $currentPage, string $mode): bool
|
||||
{
|
||||
@@ -215,7 +295,10 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available language codes.
|
||||
* Geeft de beschikbare taalcodes terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<int, string> Lijst met taalcodes.
|
||||
*/
|
||||
private function getLanguageCodes(): array
|
||||
{
|
||||
@@ -223,7 +306,11 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a slug into a readable title.
|
||||
* Formateert een slug naar een leesbare titel.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $slug De te formatteren slug.
|
||||
* @return string De geformatteerde titel.
|
||||
*/
|
||||
private function formatTitle(string $slug): string
|
||||
{
|
||||
@@ -233,8 +320,13 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the H1 title from a markdown/PHP/HTML file.
|
||||
* Falls back to formatTitle() if no H1 is found.
|
||||
* Haalt de H1-titel uit een markdown-, PHP- of HTML-bestand.
|
||||
*
|
||||
* Valt terug op formatTitle() wanneer geen H1 gevonden is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $filePath Pad naar het bestand.
|
||||
* @return string De gevonden titel, of een lege string.
|
||||
*/
|
||||
private function getTitleFromFile(string $filePath): string
|
||||
{
|
||||
@@ -253,10 +345,12 @@ class Navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the navigation items as HTML.
|
||||
* Rendert de navigatie-items als HTML.
|
||||
*
|
||||
* @param array $items Navigation items
|
||||
* @param int $depth Nesting depth (0 = top level)
|
||||
* @since 2.6.5
|
||||
* @param array $items Navigatie-items.
|
||||
* @param int $depth Nesting-diepte (0 = topniveau). Standaard 0.
|
||||
* @return string De gegenereerde HTML.
|
||||
*/
|
||||
private function renderNav(array $items, int $depth = 0): string
|
||||
{
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Statistics plugin: toont analytics-statistieken in het beheer.
|
||||
*
|
||||
* Systeem-plugin die analytics-gegevens toont, zoals totaal aantal views,
|
||||
* unieke bezoekers, landen en top-pagina's.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
class Statistics
|
||||
{
|
||||
/**
|
||||
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @var PluginAPIInterface|null
|
||||
*/
|
||||
private ?PluginAPIInterface $api = null;
|
||||
|
||||
/**
|
||||
* Stelt de Plugin-API instantie in.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||
* @return void
|
||||
*/
|
||||
public function setAPI(PluginAPIInterface $api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de plugin-configuratie terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> Plugin-configuratie met title, viewable en type.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -18,6 +45,12 @@ class Statistics
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de admin-menu-items voor deze plugin terug.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<int, array<string, mixed>> Lijst met admin-menu-item configuraties.
|
||||
*/
|
||||
public function getAdminMenu(): array
|
||||
{
|
||||
$config = $this->getPluginConfig();
|
||||
@@ -38,7 +71,12 @@ class Statistics
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this plugin's runtime config (defaults + overrides).
|
||||
* Haalt de runtime-configuratie van deze plugin op (standaardwaarden plus overrides).
|
||||
*
|
||||
* Leest standaardwaarden uit plugin.json en overschrijft deze met config.json.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return array<string, mixed> De samengevoegde plugin-configuratie.
|
||||
*/
|
||||
private function getPluginConfig(): array
|
||||
{
|
||||
@@ -64,6 +102,16 @@ class Statistics
|
||||
return array_merge($defaults, $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwerkt een admin-route en toont de statistieken-pagina.
|
||||
*
|
||||
* Toont analytics-gegevens zoals views, unieke bezoekers, landen en
|
||||
* top-pagina's, mits analytics ingeschakeld is.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @param string $action De uit te voeren actie.
|
||||
* @return string|null De HTML-uitvoer van de statistieken-pagina, of null.
|
||||
*/
|
||||
public function handleAdminRoute(string $action): ?string
|
||||
{
|
||||
// System plugin: translations resolve against the admin language.
|
||||
@@ -182,6 +230,12 @@ class Statistics
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de Analytics-instantie op basis van de analytics-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @return Analytics|null De Analytics-instantie, of null als analytics uitgeschakeld is.
|
||||
*/
|
||||
private function getAnalytics(): ?Analytics
|
||||
{
|
||||
if ($this->api === null) {
|
||||
|
||||
+1965
-207
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* Asset-server voor productie: serveert statische bestanden uit themes/,
|
||||
* admin/theme/ en plugins/.
|
||||
*
|
||||
* Dit bestand wordt op productie gebruikt wanneer de PHP-dev-router niet
|
||||
* beschikbaar is. public/.htaccess herschrijft /themes/, /admin/assets/ en
|
||||
* /plugins/ URLs naar dit bestand. Paden worden via realpath() + prefix-
|
||||
* check afgedwongen binnen de juiste asset-map om path traversal te voorkomen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
/**
|
||||
* Asset server - serves static files from themes/, admin/theme/, and plugins/
|
||||
* This file is used on production servers where the PHP router is not available.
|
||||
@@ -25,6 +37,11 @@ $mimeTypes = [
|
||||
// Determine which asset directory to serve from
|
||||
$assetFile = null;
|
||||
|
||||
/**
|
||||
* /themes/<naam>/assets/...: theme-asset matchen en pad oplossen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// /themes/<name>/assets/...
|
||||
if (preg_match('#^/themes/([^/]+)/assets/(.+)$#', $path, $m)) {
|
||||
$themeName = $m[1];
|
||||
@@ -32,12 +49,22 @@ if (preg_match('#^/themes/([^/]+)/assets/(.+)$#', $path, $m)) {
|
||||
$assetFile = $basePath . '/themes/' . $themeName . '/assets/' . $assetRel;
|
||||
$realBase = realpath($basePath . '/themes/' . $themeName . '/assets');
|
||||
}
|
||||
/**
|
||||
* /admin/assets/...: admin-theme-asset matchen (default admin theme).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// /admin/assets/...
|
||||
elseif (preg_match('#^/admin/assets/(.+)$#', $path, $m)) {
|
||||
$assetRel = $m[1];
|
||||
$assetFile = $basePath . '/admin/theme/default/assets/' . $assetRel;
|
||||
$realBase = realpath($basePath . '/admin/theme/default/assets');
|
||||
}
|
||||
/**
|
||||
* /plugins/<naam>/assets/...: plugin-asset matchen en pad oplossen.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// /plugins/<name>/assets/...
|
||||
elseif (preg_match('#^/plugins/([^/]+)/assets/(.+)$#', $path, $m)) {
|
||||
$pluginName = $m[1];
|
||||
@@ -46,6 +73,14 @@ elseif (preg_match('#^/plugins/([^/]+)/assets/(.+)$#', $path, $m)) {
|
||||
$realBase = realpath($basePath . '/plugins/' . $pluginName . '/assets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Matchende asset uitleveren met juiste MIME en cache-headers.
|
||||
*
|
||||
* Controleert dat het opgeloste pad binnen de asset-map blijft en een
|
||||
* regulier bestand is; anders volgt een 404.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
if ($assetFile && $realBase) {
|
||||
$realFile = realpath($assetFile);
|
||||
if ($realFile && strpos($realFile, $realBase) === 0 && is_file($realFile)) {
|
||||
|
||||
+102
-7
@@ -1,12 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Front-end entry point van CodePress CMS.
|
||||
*
|
||||
* Bootstrap de CMS-core, laadt de configuratie, stuurt security headers uit,
|
||||
* serveert media-/asset-/robots-requests, voert BotGuard- en rate-limit-
|
||||
* controles uit, registreert analytics- en logvermeldingen en render
|
||||
* uiteindelijk de pagina via CodePressCMS::render().
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
require_once __DIR__ . '/../cms/core/index.php';
|
||||
|
||||
$config = include __DIR__ . '/../cms/core/config.php';
|
||||
|
||||
/**
|
||||
* Log-systeem initialiseren op basis van de logging-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Initialize dynamic logging
|
||||
LogManager::init($config['logging'] ?? []);
|
||||
|
||||
/**
|
||||
* Security headers instellen voor alle front-end responses.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Security headers
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
@@ -15,6 +36,15 @@ header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Content-Security-Policy: default-src \'self\'; script-src \'self\' \'unsafe-inline\'; style-src \'self\' \'unsafe-inline\'; img-src \'self\' data:; font-src \'self\';');
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
/**
|
||||
* Media- en asset-serving via /-media/ en /-assets/ routes.
|
||||
*
|
||||
* Bepaalt het verzoekpad en de toegestane MIME-types, resolveert de
|
||||
* content-map dynamisch op basis van config['content_dir'] en serveert
|
||||
* bestanden buiten public/ met een pad-controle om traversal te blokkeren.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Serve media files from any content/ subdirectory via /-media/
|
||||
// Serve files from content/-assets/ via /-assets/ (backward compatible)
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
@@ -31,11 +61,15 @@ $mimeTypes = [
|
||||
'css' => 'text/css', 'js' => 'application/javascript',
|
||||
];
|
||||
|
||||
// Resolve the content directory once (absolute, from config) so media
|
||||
// serving follows a customised content_dir instead of a hardcoded path.
|
||||
$contentBase = realpath($config['content_dir']);
|
||||
$contentBase = $contentBase !== false ? $contentBase : rtrim($config['content_dir'], '/');
|
||||
|
||||
if (strpos($path, '/-media/') === 0) {
|
||||
$relative = ltrim(substr($path, 7), '/');
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$filePath = $root . '/content/' . $relative;
|
||||
if (strpos($filePath, $root . '/content') === 0) {
|
||||
$filePath = $contentBase . '/' . $relative;
|
||||
if ($contentBase !== '' && strpos($filePath, $contentBase) === 0) {
|
||||
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, $allowedExt) && file_exists($filePath) && !is_dir($filePath)) {
|
||||
if (isset($mimeTypes[$ext])) {
|
||||
@@ -50,18 +84,23 @@ if (strpos($path, '/-media/') === 0) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* /-assets/ route: serveert bestanden uit content/-assets/ of de content-
|
||||
* root, met backward-compatibiliteit en path-traversal-controle.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
if (strpos($path, '/-assets/') === 0) {
|
||||
$relative = ltrim(substr($path, 8), '/');
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
|
||||
$servePath = null;
|
||||
$candidates = [
|
||||
$root . '/content/-assets/' . $relative,
|
||||
$root . '/content/' . $relative,
|
||||
$contentBase . '/-assets/' . $relative,
|
||||
$contentBase . '/' . $relative,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (strpos($candidate, $root . '/content') !== 0) continue;
|
||||
if ($contentBase === '' || strpos($candidate, $contentBase) !== 0) continue;
|
||||
if (file_exists($candidate) && !is_dir($candidate)) {
|
||||
$ext = strtolower(pathinfo($candidate, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, $allowedExt)) {
|
||||
@@ -92,6 +131,13 @@ if ($path === '/robots.txt') {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directe toegang tot /content/ blokkeren.
|
||||
*
|
||||
* Voorkomt dat ruwe content-bestanden via het web opgevraagd worden.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Block direct access to content files
|
||||
if (strpos($path, '/content/') === 0) {
|
||||
http_response_code(403);
|
||||
@@ -99,6 +145,16 @@ if (strpos($path, '/content/') === 0) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security-checks en rate limiting uitvoeren.
|
||||
*
|
||||
* Bepaalt client-IP, BotGuard-controle, IP-blocklist en rate-limiter op
|
||||
* basis van de security-config. IP's op de allowed/uitgesloten lijsten
|
||||
* slaan de controles over. Het requestStatus wordt bijgehouden voor
|
||||
* latere logging en blocking.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Load RequestLogger for IP & request logging
|
||||
require_once __DIR__ . '/../cms/core/class/RequestLogger.php';
|
||||
|
||||
@@ -149,9 +205,22 @@ if (!$isAllowedIp) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CMS-instantie aanmaken met de geladen configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Instantiate CMS instance
|
||||
$cms = new CodePressCMS($config);
|
||||
|
||||
/**
|
||||
* Analytics en GeoIP-lookup uitvoeren.
|
||||
*
|
||||
* Land, IP-anonimisatie en analytics-instellingen worden bepaald op basis
|
||||
* van de analytics-configuratie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Analytics & GeoIP settings
|
||||
$analyticsSettings = $config['analytics'] ?? [];
|
||||
$geoCountry = null;
|
||||
@@ -160,11 +229,25 @@ if (!empty($analyticsSettings['enabled'])) {
|
||||
$geoCountry = $geoIp->lookupCountry($clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP-anonimisatie toepassen voor opslag indien ingeschakeld.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Apply IP anonymization for storage if enabled
|
||||
$storedIp = !empty($analyticsSettings['anonymize_ip'])
|
||||
? RequestLogger::anonymizeIp($clientIp)
|
||||
: $clientIp;
|
||||
|
||||
/**
|
||||
* Pagina-view loggen (niet voor media-/assets-requests).
|
||||
*
|
||||
* Start sessie indien nodig, stelt huidige pagina/referrer vast, schrijft
|
||||
* een request-logregel, vuurt een LogManager-requestevent en registreert
|
||||
* geaggregeerde analytics (mits ingeschakeld en IP niet uitgesloten).
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Log page view (not for media/assets)
|
||||
if (!str_starts_with($path, '/-media/') && !str_starts_with($path, '/-assets/')) {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
@@ -203,6 +286,13 @@ if (!str_starts_with($path, '/-media/') && !str_starts_with($path, '/-assets/'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Geblokkeerde verzoeken afhandelen.
|
||||
*
|
||||
* Geeft 429 bij rate-limit en 403 bij andere blokkades, met noindex-headers.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
// Block request if status is not ok
|
||||
if ($requestStatus !== 'ok') {
|
||||
if ($requestStatus === 'blocked:ratelimit') {
|
||||
@@ -216,4 +306,9 @@ if ($requestStatus !== 'ok') {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagina renderen via de CodePressCMS-instantie.
|
||||
*
|
||||
* @since 2.6.5
|
||||
*/
|
||||
$cms->render();
|
||||
+2
-18
@@ -38,11 +38,6 @@ class InstalledVersions
|
||||
*/
|
||||
private static $installed;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private static $installedIsLocalDir;
|
||||
|
||||
/**
|
||||
* @var bool|null
|
||||
*/
|
||||
@@ -320,12 +315,6 @@ class InstalledVersions
|
||||
{
|
||||
self::$installed = $data;
|
||||
self::$installedByVendor = array();
|
||||
|
||||
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
||||
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
||||
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
||||
// all installed packages for example
|
||||
self::$installedIsLocalDir = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,9 +343,7 @@ class InstalledVersions
|
||||
$copiedLocalDir = false;
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
$selfDir = self::getSelfDir();
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
$installed[] = self::$installedByVendor[$vendorDir];
|
||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||
@@ -364,14 +351,11 @@ class InstalledVersions
|
||||
$required = require $vendorDir.'/composer/installed.php';
|
||||
self::$installedByVendor[$vendorDir] = $required;
|
||||
$installed[] = $required;
|
||||
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
||||
if (strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
|
||||
self::$installed = $required;
|
||||
self::$installedIsLocalDir = true;
|
||||
$copiedLocalDir = true;
|
||||
}
|
||||
}
|
||||
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
||||
$copiedLocalDir = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
-2
@@ -1,4 +1,3 @@
|
||||
|
||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
@@ -18,4 +17,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
Vendored
+3
-2
@@ -19,7 +19,8 @@ if ($issues) {
|
||||
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException(
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
||||
trigger_error(
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues),
|
||||
E_USER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
+13
-2
@@ -1,11 +1,22 @@
|
||||
<?php
|
||||
/**
|
||||
* Versie-informatie voor CodePress CMS.
|
||||
*
|
||||
* Returnt een array met de huidige versie, releasedatum, codenaam, status,
|
||||
* systeemvereisten (PHP-versie en -extensies) en credits (auteur, licentie,
|
||||
* repository en contact). Wordt door de rest van het CMS gebruikt om de
|
||||
* versie te tonen en vereisten te valideren.
|
||||
*
|
||||
* @since 2.6.5
|
||||
* @package CodePress
|
||||
*/
|
||||
/**
|
||||
* CodePress CMS Version Information
|
||||
*/
|
||||
|
||||
return [
|
||||
'version' => '2.6.2',
|
||||
'release_date' => '2026-08-19',
|
||||
'version' => '2.6.5',
|
||||
'release_date' => '2026-08-26',
|
||||
'codename' => 'Lyra',
|
||||
'status' => 'stable',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user