Files
2026-10-25 17:46:58 +00:00

310 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
// Arbeitsspeicher nur erhöhen, nie senken (ein Limit von 512 MB oder mehr vom Hoster bleibt erhalten)
$memBytes = (function (string $v): int { $n = (int) $v; return match (strtolower(substr(trim($v), -1))) { 'g' => $n * 1073741824, 'm' => $n * 1048576, 'k' => $n * 1024, default => $n }; })((string) ini_get('memory_limit'));
if ($memBytes >= 0 && $memBytes < 512 * 1048576 && $memBytes !== 0) { @ini_set('memory_limit', '512M'); }
// 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];
// Der Backup-Ordner des Servers (backups/) gehört NIE ins Backup, auch wenn das Ziel ein anderer Ordner ist (zum Beispiel ein Zwischenordner des
// NAS-Plugins): sonst steckten alle älteren Backups noch einmal darin. Nur die .htaccess bleibt dabei, damit der Ordner nach einer
// Wiederherstellung geschützt ist.
$stdBackups = realpath($rootDir . '/backups') ?: null;
$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;
if ($stdBackups !== null && strpos($filePath, $stdBackups . DIRECTORY_SEPARATOR) === 0) {
if ($filePath !== $stdBackups . DIRECTORY_SEPARATOR . '.htaccess') $skip = true; // alles außer der .htaccess
} else {
foreach ($exclude as $ex) {
if ($ex && ($filePath === $ex || strpos($filePath, rtrim($ex, '/' . chr(92)) . DIRECTORY_SEPARATOR) === 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.'];
}
$canStore = method_exists($zip, 'setCompressionName');
foreach ($files as $filePath => $relativePath) {
$zip->addFile($filePath, $relativePath);
// Bereits komprimierte Dateien nur ablegen statt erneut zu packen: spart bei großen Uploads viel Rechenzeit
if ($canStore && preg_match('/\.(zip|jpe?g|png|gif|webp|pdf|exe|apk|aab|jar|7z|gz|mp[34]|woff2?)$/i', $relativePath)) {
@$zip->setCompressionName($relativePath, ZipArchive::CM_STORE);
}
}
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),
];
}
/**
* Sperre „es läuft gerade ein Backup“: höchstens ein Backup gleichzeitig (Cronjob, eingebauter Hintergrunddienst, Knopf auf der Seite, NAS-Plugin).
* Atomar über die Einstellungstabelle; eine Sperre älter als $maxSeconds (abgestürzter Lauf) gilt als frei.
*/
function backup_lock_acquire(PDO $pdo, int $maxSeconds = 10800): bool
{
if (!function_exists('pseudo_cron_try_claim')) return true;
return pseudo_cron_try_claim($pdo, 'backup_run_lock', $maxSeconds);
}
function backup_lock_release(PDO $pdo): void
{
if (!function_exists('pseudo_cron_try_claim')) return;
try { $pdo->prepare("UPDATE settings SET setting_value = '1970-01-01 00:00:00' WHERE setting_key = 'backup_run_lock'")->execute(); } catch (Throwable $e) { /* Sperre verfällt von selbst */ }
}