279 lines
14 KiB
PHP
279 lines
14 KiB
PHP
<?php
|
||
/**
|
||
* Backup-Hilfsfunktion – wird von update.php, backup.php und den Cron-Jobs genutzt.
|
||
*
|
||
* Ein Backup ist eine ZIP-Datei mit
|
||
* - allen Programm- und Datendateien (ohne Backups, Entwickler-Artefakte),
|
||
* - einem konsistenten Datenbank-Abzug: database_backup.sqlite (SQLite, per VACUUM INTO) oder database_backup.sql (MySQL),
|
||
* - backup_manifest.json (Zeitpunkt, Datenbanktyp, Programmversion, Anzahl Tabellen).
|
||
* Wiederherstellen und Prüfen: includes/backup_restore.php.
|
||
*/
|
||
|
||
require_once __DIR__ . '/simple_zip.php';
|
||
|
||
/** Pfade, die nie ins Backup gehören (Entwickler-Artefakte und Zwischenspeicher). */
|
||
function backup_excluded_fragments(): array
|
||
{
|
||
return ['/node_modules/', '/.git/', '/desktop/dist/', '/mobile/build/', '/mobile/artifacts/', '/plugin-webseite/storage/', '/plugin-webseite/schluessel/'];
|
||
}
|
||
|
||
// ── Backup erstellen ───────────────────────────────────────────────�
|
||
/**
|
||
* @param string|null $rootDir Ordner, dessen Dateien gesichert werden (Standard: DeskHub-Hauptordner)
|
||
* @param string|null $backupDir Zielordner (Standard: backups/ im Hauptordner)
|
||
* @param PDO|null $pdo Datenbankverbindung (Standard: get_db())
|
||
* @param array $options Optional: 'with_database' (bool, Standard true), 'with_files' (bool, Standard true),
|
||
* 'only' (Liste von Pfaden relativ zum Hauptordner, nur diese Dateien/Ordner sichern),
|
||
* 'exclude' (Liste von Pfaden relativ zum Hauptordner, die nicht gesichert werden),
|
||
* 'verify' (bool, Standard true: Backup sofort automatisch auf Wiederherstellbarkeit prüfen und das Ergebnis merken)
|
||
*/
|
||
function create_backup(?string $rootDir = null, ?string $backupDir = null, ?PDO $pdo = null, array $options = []): array
|
||
{
|
||
$withDb = $options['with_database'] ?? true;
|
||
$withFiles = $options['with_files'] ?? true;
|
||
$norm = fn(string $p) => trim(str_replace(chr(92), '/', $p), '/');
|
||
$onlyRel = array_values(array_filter(array_map($norm, (array) ($options['only'] ?? []))));
|
||
$excludeRel = array_values(array_filter(array_map($norm, (array) ($options['exclude'] ?? []))));
|
||
$rootDir = realpath($rootDir ?? __DIR__ . '/..');
|
||
$backupDir = $backupDir ?? $rootDir . '/backups';
|
||
$pdo = $pdo ?? get_db();
|
||
|
||
if (!is_dir($backupDir)) {
|
||
mkdir($backupDir, 0755, true);
|
||
}
|
||
|
||
// .htaccess zum Schutz des Backup-Ordners
|
||
$htaccess = $backupDir . '/.htaccess';
|
||
if (!file_exists($htaccess)) {
|
||
file_put_contents($htaccess, "Require all denied
|
||
Order deny,allow
|
||
Deny from all
|
||
");
|
||
}
|
||
|
||
$backupName = 'backup_' . date('Y-m-d_H-i-s') . '.zip';
|
||
$backupPath = $backupDir . '/' . $backupName;
|
||
if (file_exists($backupPath)) { // zwei Backups in derselben Sekunde
|
||
$backupName = 'backup_' . date('Y-m-d_H-i-s') . '_' . substr(bin2hex(random_bytes(2)), 0, 3) . '.zip';
|
||
$backupPath = $backupDir . '/' . $backupName;
|
||
}
|
||
|
||
// Zeitlimit erhöhen für große Backups
|
||
@set_time_limit(300);
|
||
@ini_set('memory_limit', '256M');
|
||
|
||
// php_zip automatisch aktivieren falls nicht geladen
|
||
if (!class_exists('ZipArchive')) {
|
||
$activated = false;
|
||
|
||
// 1. Dynamisch laden (funktioniert wenn dl() erlaubt)
|
||
if (function_exists('dl') && ini_get('enable_dl')) {
|
||
$activated = @dl('php_zip.dll') || @dl('zip.so') || @dl('zip');
|
||
}
|
||
|
||
// 2. php.ini dauerhaft anpassen (für nächsten Start)
|
||
if (!$activated) {
|
||
$phpIni = php_ini_loaded_file();
|
||
if ($phpIni && is_writable($phpIni)) {
|
||
$ini = file_get_contents($phpIni);
|
||
$patched = preg_replace('/^[ \t]*;[ \t]*(extension[ \t]*=[ \t]*(?:php_)?zip(?:\.dll|\.so)?)/mi', '$1', $ini);
|
||
if ($patched !== $ini) {
|
||
file_put_contents($phpIni, $patched);
|
||
// Hinweis speichern dass Neustart nötig ist
|
||
file_put_contents($backupDir . '/.restart_required', '1');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Datenbank-Abzug: SQLite-Datei wird NICHT als laufende Datei kopiert, sondern konsistent per VACUUM INTO gesichert
|
||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$liveDb = null;
|
||
if ($driver === 'sqlite') {
|
||
$row = $pdo->query('PRAGMA database_list')->fetch(PDO::FETCH_ASSOC);
|
||
$liveDb = !empty($row['file']) ? realpath($row['file']) : null;
|
||
}
|
||
|
||
// Alle Dateien sammeln
|
||
$exclude = [realpath($backupDir) ?: $backupDir];
|
||
$iter = new RecursiveIteratorIterator(
|
||
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||
RecursiveIteratorIterator::SELF_FIRST
|
||
);
|
||
|
||
$files = [];
|
||
foreach ($withFiles ? $iter : [] as $file) {
|
||
if (!$file->isFile()) continue;
|
||
$filePath = $file->getRealPath();
|
||
$norm = str_replace('\\', '/', $filePath);
|
||
$skip = false;
|
||
foreach ($exclude as $ex) {
|
||
if ($ex && strpos($filePath, $ex) === 0) { $skip = true; break; }
|
||
}
|
||
foreach (backup_excluded_fragments() as $frag) {
|
||
if (str_contains($norm, $frag)) { $skip = true; break; }
|
||
}
|
||
if ($liveDb && ($filePath === $liveDb || $filePath === $liveDb . '-wal' || $filePath === $liveDb . '-shm' || $filePath === $liveDb . '-journal')) $skip = true;
|
||
if ($skip) continue;
|
||
$rel = str_replace(chr(92), '/', substr($filePath, strlen($rootDir) + 1)); // im ZIP immer mit /
|
||
// Auswahl und Ausschlüsse (relativ zum Hauptordner, ganze Ordner oder einzelne Dateien)
|
||
$under = fn(array $list) => (bool) array_filter($list, fn($p) => $rel === $p || str_starts_with($rel, $p . '/'));
|
||
if ($onlyRel && !$under($onlyRel)) continue;
|
||
if ($excludeRel && $under($excludeRel)) continue;
|
||
$files[$filePath] = $rel;
|
||
}
|
||
|
||
// Dateien, die der Webserver-Benutzer nicht lesen kann (fremde Besitzer/Rechte), würden das ganze Backup scheitern lassen: überspringen und melden
|
||
$skipped = [];
|
||
foreach ($files as $filePath => $relativePath) {
|
||
if (!is_readable($filePath)) { $skipped[] = $relativePath; unset($files[$filePath]); }
|
||
}
|
||
|
||
// Abzug erzeugen
|
||
$sqlDump = null; // MySQL: Text
|
||
$sqlFile = null; // MySQL: Dateiname im ZIP
|
||
$sqliteSnapshot = null;
|
||
$tableCount = 0;
|
||
$dbError = null;
|
||
|
||
if ($withDb && $driver === 'sqlite') {
|
||
$snapshot = tempnam(sys_get_temp_dir(), 'dhbk');
|
||
@unlink($snapshot);
|
||
try {
|
||
$pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));
|
||
$sqliteSnapshot = $snapshot;
|
||
} catch (Throwable $e) {
|
||
// Ältere SQLite-Version ohne VACUUM INTO: Datei kopieren (weniger sicher, aber besser als nichts)
|
||
if ($liveDb && @copy($liveDb, $snapshot)) $sqliteSnapshot = $snapshot;
|
||
else $dbError = 'SQLite-Abzug fehlgeschlagen: ' . $e->getMessage();
|
||
}
|
||
if ($sqliteSnapshot) {
|
||
$tableCount = (int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchColumn();
|
||
}
|
||
} elseif ($withDb && $driver === 'mysql') {
|
||
try {
|
||
$dbName = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
|
||
$dump = "-- DeskHub MySQL Backup\n-- Erstellt: " . date('Y-m-d H:i:s') . "\n-- Datenbank: " . $dbName . "\n\nSET FOREIGN_KEY_CHECKS=0;\n\n";
|
||
|
||
// Alle Tabellen auslesen
|
||
$tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN);
|
||
$tableCount = count($tables);
|
||
|
||
foreach ($tables as $table) {
|
||
// CREATE TABLE
|
||
$create = $pdo->query("SHOW CREATE TABLE `$table`")->fetch();
|
||
$dump .= "\n-- Tabelle: $table\n";
|
||
$dump .= "DROP TABLE IF EXISTS `$table`;\n";
|
||
$dump .= $create['Create Table'] . ";\n\n";
|
||
|
||
// Daten
|
||
$rows = $pdo->query("SELECT * FROM `$table`")->fetchAll(PDO::FETCH_ASSOC);
|
||
if ($rows) {
|
||
$cols = '`' . implode('`, `', array_keys($rows[0])) . '`';
|
||
$dump .= "INSERT INTO `$table` ($cols) VALUES\n";
|
||
$vals = [];
|
||
foreach ($rows as $row) {
|
||
$escaped = array_map(fn($v) => $v === null ? 'NULL' : $pdo->quote((string)$v), $row);
|
||
$vals[] = '(' . implode(', ', $escaped) . ')';
|
||
}
|
||
$dump .= implode(",\n", $vals) . ";\n\n";
|
||
}
|
||
}
|
||
|
||
$dump .= "SET FOREIGN_KEY_CHECKS=1;\n";
|
||
$sqlFile = 'database_backup.sql';
|
||
$sqlDump = $dump;
|
||
} catch (Throwable $e) {
|
||
$dbError = 'MySQL-Abzug fehlgeschlagen: ' . $e->getMessage();
|
||
}
|
||
}
|
||
// Ohne Datenbank-Abzug ist es kein brauchbares Backup: lieber Fehler melden als ein Backup vortäuschen
|
||
if ($dbError !== null) {
|
||
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
|
||
return ['ok' => false, 'error' => $dbError];
|
||
}
|
||
|
||
$manifest = json_encode([
|
||
'created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null,
|
||
'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files), 'skipped' => array_slice($skipped, 0, 50),
|
||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||
|
||
// ZIP erstellen – ZipArchive wenn verfügbar, sonst eigene Implementierung
|
||
try {
|
||
if (class_exists('ZipArchive')) {
|
||
$zip = new ZipArchive();
|
||
if ($zip->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||
return ['ok' => false, 'error' => 'Backup-ZIP konnte nicht erstellt werden.'];
|
||
}
|
||
foreach ($files as $filePath => $relativePath) {
|
||
$zip->addFile($filePath, $relativePath);
|
||
}
|
||
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
|
||
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
|
||
$zip->addFromString('backup_manifest.json', $manifest);
|
||
$closed = @$zip->close();
|
||
if (!$closed || !file_exists($backupPath)) {
|
||
$zipReason = trim((string) $zip->getStatusString());
|
||
// Zweiter Versuch: nur Dateien, die sich wirklich öffnen lassen
|
||
@unlink($backupPath);
|
||
$retry = new ZipArchive();
|
||
if ($retry->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
|
||
foreach ($files as $filePath => $relativePath) {
|
||
$h = @fopen($filePath, 'rb');
|
||
if ($h === false) { $skipped[] = $relativePath; continue; }
|
||
fclose($h);
|
||
$retry->addFile($filePath, $relativePath);
|
||
}
|
||
if ($sqlDump && $sqlFile) $retry->addFromString($sqlFile, $sqlDump);
|
||
if ($sqliteSnapshot) $retry->addFile($sqliteSnapshot, 'database_backup.sqlite');
|
||
$retry->addFromString('backup_manifest.json', json_encode(['created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null, 'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files) - count($skipped), 'skipped' => array_slice($skipped, 0, 50)], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||
@$retry->close();
|
||
if (!file_exists($backupPath)) $zipReason = trim($zipReason . ' ' . $retry->getStatusString());
|
||
}
|
||
if (!file_exists($backupPath)) {
|
||
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
|
||
$free = @disk_free_space($backupDir);
|
||
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt' . ($zipReason !== '' ? ' (' . $zipReason . ')' : '')
|
||
. '. Prüfen: Ist der Ordner „' . basename($backupDir) . '“ beschreibbar' . ($free !== false ? ' und ist genug Speicherplatz frei (aktuell ' . round($free / 1048576) . ' MB)' : '') . '?'];
|
||
}
|
||
}
|
||
} else {
|
||
// Eigene ZIP-Implementierung (kein php_zip nötig)
|
||
$zip = new SimpleZip($backupPath);
|
||
foreach ($files as $filePath => $relativePath) {
|
||
$zip->addFile($filePath, $relativePath);
|
||
}
|
||
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
|
||
if ($sqliteSnapshot) $zip->addFromString('database_backup.sqlite', (string) file_get_contents($sqliteSnapshot));
|
||
$zip->addFromString('backup_manifest.json', $manifest);
|
||
$zip->close();
|
||
}
|
||
} catch (Exception $e) {
|
||
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
|
||
return ['ok' => false, 'error' => 'Backup fehlgeschlagen: ' . $e->getMessage()];
|
||
}
|
||
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
|
||
|
||
if (!file_exists($backupPath)) {
|
||
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt.'];
|
||
}
|
||
|
||
// Automatische Prüfung: Ein Backup, das sich nicht wiederherstellen lässt, ist wertlos. Das Ergebnis steht in der Liste und im Hinweis im System.
|
||
// (Backups ohne Datenbank, zum Beispiel reine Datei-Sicherungen von Plugins, lassen sich so nicht prüfen und werden hier ausgelassen.)
|
||
$verify = null;
|
||
if ($withDb && ($options['verify'] ?? true)) {
|
||
if (!function_exists('backup_verify_and_record')) require_once __DIR__ . '/backup_restore.php';
|
||
$verify = backup_verify_and_record($backupPath);
|
||
}
|
||
|
||
return [
|
||
'ok' => true,
|
||
'filename' => $backupName,
|
||
'path' => $backupPath,
|
||
'files' => count($files),
|
||
'skipped' => $skipped,
|
||
'verify' => $verify,
|
||
'size' => round(filesize($backupPath) / 1024 / 1024, 2),
|
||
];
|
||
}
|