contentDir = rtrim($contentDir, '/'); $this->projectRoot = $projectRoot !== '' ? rtrim($projectRoot, '/') : dirname(__DIR__, 3); $this->gitAvailable = $this->detectGit(); } /** * Controleer of git beschikbaar is en de content-map in een git-repo zit. * * @since 2.6.4 * * @return bool True indien git beschikbaar is. */ public function isGitAvailable(): bool { return $this->gitAvailable; } /** * Maak een ZIP-backup van de content-map. * * 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.4 * * @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 { 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(); } /** * Herstel content vanuit een ZIP-bestand. * * 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.4 * * @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 { 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.']; } /** * Initialiseer een git-repository in de content-map. * * Voert `git init` uit en configureert een default identiteit (user.email * en user.name) zodat commits werken zonder globale git-config. * * @since 2.6.4 * * @return array{success:bool,message:string} Resultaat-array met success en message. */ 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 alle wijzigingen in de content-map. * * 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.4 * * @param string $message Commit message. * @return array{success:bool,message:string} Resultaat-array met success en message. */ 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]; } /** * Haal de git-log op voor de content-map. * * 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.4 * * @param int $limit Maximum aantal entries. Default 20. * @return array{success:bool,commits:array,message:string} Resultaat-array met success, commits en message. */ 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' => '']; } /** * Herstel content naar een specifieke git-commit. * * Controleert de commit-hash, saniteert deze en voert * `git checkout -- .` uit om de bestanden van die commit * in de working tree te herstellen. * * @since 2.6.4 * * @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 { 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 -- . 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)]; } /** * Controleer of de content-map een git-repository bevat. * * @since 2.6.4 * * @return bool True indien een .git-map aanwezig is. */ public function hasGitRepo(): bool { return is_dir($this->contentDir . '/.git'); } // ----------------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------------- /** * Detecteer of git beschikbaar is op het systeem. * * Voert `git --version` uit en controleert de exit-code. * * @since 2.6.4 * * @return bool True indien git uitvoerbaar is. */ private function detectGit(): bool { $result = $this->execGitCapture(['--version']); return $result['exit'] === 0; } /** * Voer een git-commando uit en capture output, error en exit-code apart. * * Gebruikt proc_open met aparte pipes voor stdout, stderr en stdin. * Alle argumenten worden via escapeshellarg ge-escaped. * * @since 2.6.4 * * @param array $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 { $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, ]; } /** * 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.4 * * @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); 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); } } } /** * 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.4 * * @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)) { 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); } } } /** * Verwijder een map en alle inhoud recursief. * * Verwijdert eerst alle bestanden en submappen, daarna de map zelf. * * @since 2.6.4 * * @param string $dir Pad naar de te verwijderen map. * @return void */ 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); } /** * 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.4 * * @return void */ 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); } } }