536 lines
17 KiB
PHP
536 lines
17 KiB
PHP
<?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)
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
class ContentBackup
|
|
{
|
|
/**
|
|
* Pad naar de content-map.
|
|
*
|
|
* @since 2.6.4
|
|
* @var string Pad naar de content-map.
|
|
*/
|
|
private string $contentDir;
|
|
|
|
/**
|
|
* Pad naar de project-root.
|
|
*
|
|
* @since 2.6.4
|
|
* @var string Pad naar de project-root.
|
|
*/
|
|
private string $projectRoot;
|
|
|
|
/**
|
|
* Of git beschikbaar is in de content-map.
|
|
*
|
|
* @since 2.6.4
|
|
* @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.4
|
|
*
|
|
* @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, '/');
|
|
$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<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
|
|
{
|
|
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 <hash> -- .` 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 <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)];
|
|
}
|
|
|
|
/**
|
|
* 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<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
|
|
{
|
|
$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);
|
|
}
|
|
}
|
|
} |