v2.6.0: Content backup/git versioning, plugin type system, docs update

New features:
- ContentBackup class with ZIP backup/restore and git versioning
- Admin backup & restore page (content-backup.twig) with git init/commit/log/restore
- Plugin type system: system (blue) vs content (green) with visual badges
- PluginAPIInterface + AdminPluginAPI for plugin architecture
- Essential plugin flag (cannot edit/deactivate/delete)

Improvements:
- Consolidated enabled_plugins config (removed plugins.enabled)
- Removed Analytics/Logging toggles from admin config page
- Fixed Dashboard plugin Twig comments rendered as text
- Updated 20 guide files (NL+EN): configuratie, plugins, plugin-development,
  core-classes, theme-json, layouts, scss-styling, admin-beheerder, nieuw-thema, architectuur
- Improved accessibility test script (grep -E, min/max checks)

Cleanup:
- Removed unused classes: ARIAComponents, AccessibilityManager, ContentSecurityPolicy, etc.
- Removed vendor packages: mustache/mustache, php-mqtt/client
- Removed old templates: logs.twig, statistics.twig (now plugins)
- Moved language files to language/ directory

Tests:
- Pentest: 30/30 passed, 0 vulnerabilities
- WCAG 2.1 AA: 25/25 passed, 100% compliance
This commit is contained in:
2026-08-15 19:21:04 +02:00
parent 3a55ea4db6
commit 20adea7544
208 changed files with 3763 additions and 22688 deletions
+401
View File
@@ -0,0 +1,401 @@
<?php
/**
* ContentBackup — backup and restore the content directory.
*
* Supports:
* - ZIP backup of the entire content directory
* - Restore from an uploaded ZIP file
* - Optional git-based versioning (init / commit / log / restore)
*/
class ContentBackup
{
private string $contentDir;
private string $projectRoot;
private bool $gitAvailable;
public function __construct(string $contentDir, string $projectRoot = '')
{
$this->contentDir = rtrim($contentDir, '/');
$this->projectRoot = $projectRoot !== '' ? rtrim($projectRoot, '/') : dirname(__DIR__, 3);
$this->gitAvailable = $this->detectGit();
}
/**
* Check whether git is available and the content dir is inside a git repo.
*/
public function isGitAvailable(): bool
{
return $this->gitAvailable;
}
/**
* Create a ZIP backup of the content directory.
*
* @param string $outputPath Where to write the ZIP file
* @return bool True on success
*/
public function createZipBackup(string $outputPath): bool
{
if (!class_exists('ZipArchive')) {
return false;
}
if (!is_dir($this->contentDir)) {
return false;
}
$zip = new ZipArchive();
if ($zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return false;
}
$this->addDirToZip($zip, $this->contentDir, 'content');
return $zip->close();
}
/**
* Restore content from a ZIP file.
*
* @param string $zipPath Path to the uploaded ZIP file
* @return array ['success' => bool, 'message' => string]
*/
public function restoreFromZip(string $zipPath): array
{
if (!class_exists('ZipArchive')) {
return ['success' => false, 'message' => 'ZipArchive niet beschikbaar.'];
}
if (!file_exists($zipPath)) {
return ['success' => false, 'message' => 'ZIP bestand niet gevonden.'];
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
return ['success' => false, 'message' => 'Kan ZIP bestand niet openen.'];
}
$tempDir = $this->projectRoot . '/var/tmp/restore_' . date('YmdHis');
if (!@mkdir($tempDir, 0755, true)) {
$zip->close();
return ['success' => false, 'message' => 'Kan tijdelijke map niet aanmaken.'];
}
$zip->extractTo($tempDir);
$zip->close();
// Determine source: either $tempDir/content/ or $tempDir/ itself
$sourceDir = is_dir($tempDir . '/content') ? $tempDir . '/content' : $tempDir;
// Backup current content before overwriting
$backupSubDir = $this->contentDir . '.bak.' . date('YmdHis');
if (is_dir($this->contentDir)) {
rename($this->contentDir, $backupSubDir);
}
if (!@mkdir($this->contentDir, 0755, true)) {
// Restore old content if mkdir fails
if (is_dir($backupSubDir)) {
rename($backupSubDir, $this->contentDir);
}
$this->removeDir($tempDir);
return ['success' => false, 'message' => 'Kan content map niet aanmaken.'];
}
$this->copyDir($sourceDir, $this->contentDir);
// Clean up temp dir
$this->removeDir($tempDir);
// Remove old backup (keep last one)
$this->cleanupOldBackups();
return ['success' => true, 'message' => 'Content succesvol hersteld.'];
}
/**
* Initialize git in the content directory.
*
* @return array ['success' => bool, 'message' => string]
*/
public function gitInit(): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
}
$result = $this->execGitCapture(['init'], $this->contentDir);
if ($result['exit'] !== 0) {
return ['success' => false, 'message' => 'Git init mislukt: ' . trim($result['error'])];
}
// Configure a default identity so commits work without global git config
$this->execGitCapture(['config', 'user.email', 'content@codepress.local'], $this->contentDir);
$this->execGitCapture(['config', 'user.name', 'CodePress CMS'], $this->contentDir);
return ['success' => true, 'message' => 'Git repository geïnitialiseerd in content/.'];
}
/**
* Commit all changes in the content directory.
*
* @param string $message Commit message
* @return array ['success' => bool, 'message' => string]
*/
public function gitCommit(string $message): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
}
if (!$this->hasGitRepo()) {
return ['success' => false, 'message' => 'Geen git repository in content/. Voer eerst git init uit.'];
}
$msg = trim($message) !== '' ? $message : 'Content update ' . date('Y-m-d H:i:s');
// Add all files
$addResult = $this->execGitCapture(['add', '-A'], $this->contentDir);
if ($addResult['exit'] !== 0) {
return ['success' => false, 'message' => 'Git add mislukt: ' . trim($addResult['error'])];
}
// Check if there are changes to commit
$statusResult = $this->execGitCapture(['status', '--porcelain'], $this->contentDir);
if (trim($statusResult['output']) === '') {
return ['success' => true, 'message' => 'Geen wijzigingen om te committen.'];
}
$commitResult = $this->execGitCapture(['commit', '-m', $msg], $this->contentDir);
if ($commitResult['exit'] !== 0) {
return ['success' => false, 'message' => 'Git commit mislukt: ' . trim($commitResult['error'])];
}
return ['success' => true, 'message' => 'Wijzigingen gecommit: ' . $msg];
}
/**
* Get the git log for the content directory.
*
* @param int $limit Maximum number of entries
* @return array ['success' => bool, 'commits' => array, 'message' => string]
*/
public function gitLog(int $limit = 20): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'commits' => [], 'message' => 'Git is niet beschikbaar.'];
}
if (!$this->hasGitRepo()) {
return ['success' => false, 'commits' => [], 'message' => 'Geen git repository.'];
}
$result = $this->execGitCapture(
['log', '--pretty=format:%H|%h|%ai|%s', '-' . (string)$limit],
$this->contentDir
);
if ($result['exit'] !== 0) {
// No commits yet is not an error in our context
if (str_contains($result['error'], 'does not have any commits')) {
return ['success' => true, 'commits' => [], 'message' => ''];
}
return ['success' => false, 'commits' => [], 'message' => 'Kan git log niet uitlezen: ' . trim($result['error'])];
}
$commits = [];
$lines = explode("\n", trim($result['output']));
if ($lines !== ['']) {
foreach ($lines as $line) {
$parts = explode('|', $line, 4);
if (count($parts) === 4) {
$commits[] = [
'hash' => $parts[0],
'short' => $parts[1],
'date' => $parts[2],
'message' => $parts[3],
];
}
}
}
return ['success' => true, 'commits' => $commits, 'message' => ''];
}
/**
* Restore content to a specific git commit.
*
* @param string $commitHash Commit hash to restore to
* @return array ['success' => bool, 'message' => string]
*/
public function gitRestore(string $commitHash): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
}
if (!$this->hasGitRepo()) {
return ['success' => false, 'message' => 'Geen git repository.'];
}
$hash = preg_replace('/[^a-f0-9]/', '', $commitHash);
if ($hash === '') {
return ['success' => false, 'message' => 'Ongeldige commit hash.'];
}
// checkout <hash> -- . restores files from that commit into the working tree
$result = $this->execGitCapture(['checkout', $hash, '--', '.'], $this->contentDir);
if ($result['exit'] !== 0) {
return ['success' => false, 'message' => 'Git restore mislukt: ' . trim($result['error'])];
}
return ['success' => true, 'message' => 'Content hersteld naar commit ' . substr($hash, 0, 7)];
}
/**
* Check if the content directory has a git repository.
*/
public function hasGitRepo(): bool
{
return is_dir($this->contentDir . '/.git');
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
private function detectGit(): bool
{
$result = $this->execGitCapture(['--version']);
return $result['exit'] === 0;
}
/**
* Execute a git command and capture output, error, and exit code separately.
*
* @return array ['output' => string, 'error' => string, 'exit' => int]
*/
private function execGitCapture(array $args, ?string $cwd = null): array
{
$escaped = array_map('escapeshellarg', $args);
$command = 'git ' . implode(' ', $escaped);
$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
$process = proc_open($command, $descriptors, $pipes, $cwd ?? null);
if (!is_resource($process)) {
return ['output' => '', 'error' => 'Kan git proces niet starten.', 'exit' => -1];
}
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return [
'output' => $output !== false ? $output : '',
'error' => $error !== false ? $error : '',
'exit' => $exitCode,
];
}
private function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
{
$entries = scandir($dir);
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
$zipPath = $prefix . '/' . $entry;
if (is_dir($path)) {
$zip->addEmptyDir($zipPath);
$this->addDirToZip($zip, $path, $zipPath);
} elseif (is_file($path)) {
$zip->addFile($path, $zipPath);
}
}
}
private function copyDir(string $src, string $dst): void
{
if (!is_dir($src)) {
return;
}
if (!is_dir($dst)) {
@mkdir($dst, 0755, true);
}
$entries = scandir($src);
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$srcPath = $src . '/' . $entry;
$dstPath = $dst . '/' . $entry;
if (is_dir($srcPath)) {
$this->copyDir($srcPath, $dstPath);
} elseif (is_file($srcPath)) {
copy($srcPath, $dstPath);
}
}
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$entries = scandir($dir);
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
if (is_dir($path)) {
$this->removeDir($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
private function cleanupOldBackups(): void
{
$parentDir = dirname($this->contentDir);
$baseName = basename($this->contentDir);
$pattern = $parentDir . '/' . $baseName . '.bak.*';
$backups = glob($pattern);
if ($backups === false || count($backups) <= 1) {
return;
}
// Sort by modification time (oldest first)
usort($backups, function ($a, $b) {
return filemtime($a) - filemtime($b);
});
// Remove all but the last backup
$toRemove = array_slice($backups, 0, count($backups) - 1);
foreach ($toRemove as $backup) {
$this->removeDir($backup);
}
}
}