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 */ } }