Files
2026-10-19 18:05:57 +00:00

344 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* Backups prüfen und wiederherstellen (Gegenstück zu create_backup in includes/backup.php).
*
* backup_verify($zip) prüft, ob ein Backup vollständig und lesbar ist, ohne etwas zu verändern
* backup_restore($pdo, $zip, …) stellt Datenbank und (wählbar) Dateien wieder her; vorher wird ein Sicherheits-Backup erstellt
*
* Programmdateien (includes/, staff/, config/ …) werden nie überschrieben: Dafür gibt es das System-Update. Wiederhergestellt werden
* die Datenbank sowie die Ordner uploads/, plugins/ und app_downloads/.
*/
require_once __DIR__ . '/backup.php';
/** Ordner im Backup, die beim Wiederherstellen zurückgespielt werden dürfen. */
function backup_restorable_dirs(): array
{
return ['uploads' => 'Hochgeladene Dateien (Anhänge)', 'plugins' => 'Plugins', 'app_downloads' => 'App-Dateien'];
}
/** Ist der Eintragsname im ZIP unbedenklich (keine .., keine absoluten Pfade)? */
function backup_entry_name_ok(string $name): bool
{
$n = str_replace('\\', '/', $name);
return $n !== '' && $n[0] !== '/' && !str_contains($n, "\0") && !preg_match('#(^|/)\.\.(/|$)#', $n) && !preg_match('#^[A-Za-z]:#', $n);
}
/** Zerlegt einen SQL-Dump in Anweisungen (Zeilenweise, mit Beachtung von Zeichenketten). */
function backup_sql_statements(string $file): Generator
{
$fh = fopen($file, 'rb');
$buf = '';
$inQuote = false;
while (($line = fgets($fh)) !== false) {
if (!$inQuote && $buf === '' && (trim($line) === '' || str_starts_with($line, '--'))) continue;
$buf .= $line;
// Anführungszeichen-Zustand über die Zeile fortschreiben
$n = strlen($line);
for ($i = 0; $i < $n; $i++) {
$c = $line[$i];
if ($inQuote) {
if ($c === '\\') { $i++; continue; }
if ($c === "'") $inQuote = false;
} elseif ($c === "'") {
$inQuote = true;
}
}
if (!$inQuote && substr(rtrim($line), -1) === ';') {
yield trim($buf);
$buf = '';
}
}
if (trim($buf) !== '') yield trim($buf);
fclose($fh);
}
/** Entpackt einen Eintrag in eine temporäre Datei (Streaming, auch für große Dateien). */
function backup_extract_entry(ZipArchive $zip, string $name): ?string
{
$in = $zip->getStream($name);
if (!$in) return null;
$tmp = tempnam(sys_get_temp_dir(), 'dhrs');
$out = fopen($tmp, 'wb');
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
return $tmp;
}
/**
* Prüft ein Backup, ohne etwas zu verändern.
*
* @return array{ok:bool, errors:string[], warnings:string[], info:array}
*/
function backup_verify(string $zipPath): array
{
$r = ['ok' => false, 'errors' => [], 'warnings' => [], 'info' => []];
if (!class_exists('ZipArchive')) { $r['errors'][] = 'Die PHP-Erweiterung zip fehlt.'; return $r; }
if (!is_file($zipPath) || filesize($zipPath) < 100) { $r['errors'][] = 'Die Backup-Datei fehlt oder ist leer.'; return $r; }
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CHECKCONS) !== true) { $r['errors'][] = 'Die ZIP-Datei ist beschädigt oder unvollständig.'; return $r; }
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$n = (string) $zip->getNameIndex($i);
if (!backup_entry_name_ok($n)) { $zip->close(); $r['errors'][] = "Unzulässiger Pfad im Backup: {$n}"; return $r; }
$names[$n] = true;
}
$r['info']['files'] = count($names);
$manifest = null;
if (isset($names['backup_manifest.json'])) {
$manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true);
if (!is_array($manifest)) { $r['errors'][] = 'Das Manifest im Backup ist unlesbar.'; $manifest = null; }
}
$r['info']['created'] = $manifest['created'] ?? null;
$r['info']['version'] = $manifest['version'] ?? null;
$key = ['tickets', 'users', 'staff', 'settings'];
if (isset($names['database_backup.sqlite'])) {
$r['info']['driver'] = 'sqlite';
$tmp = backup_extract_entry($zip, 'database_backup.sqlite');
if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') {
$r['errors'][] = 'Der Datenbank-Abzug im Backup ist keine gültige SQLite-Datenbank.';
} else {
try {
$db = new PDO('sqlite:' . $tmp);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$integrity = $db->query('PRAGMA integrity_check')->fetchColumn();
if ($integrity !== 'ok') $r['errors'][] = 'Die Datenbank im Backup ist beschädigt: ' . $integrity;
$tables = $db->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
$r['info']['tables'] = count($tables);
foreach ($key as $t) if (!in_array($t, $tables, true)) $r['errors'][] = "In der Datenbank des Backups fehlt die Tabelle \"{$t}\".";
foreach (['tickets', 'users', 'staff'] as $t) if (in_array($t, $tables, true)) $r['info'][$t] = (int) $db->query("SELECT COUNT(*) FROM {$t}")->fetchColumn();
} catch (Throwable $e) {
$r['errors'][] = 'Die Datenbank im Backup lässt sich nicht lesen: ' . $e->getMessage();
}
$db = null;
}
if ($tmp) @unlink($tmp);
} elseif (isset($names['database_backup.sql'])) {
$r['info']['driver'] = 'mysql';
$tmp = backup_extract_entry($zip, 'database_backup.sql');
if (!$tmp) {
$r['errors'][] = 'Der Datenbank-Abzug im Backup ist nicht lesbar.';
} else {
$creates = [];
$count = 0;
$last = '';
foreach (backup_sql_statements($tmp) as $stmt) {
$count++;
$last = $stmt;
if (preg_match('/^CREATE TABLE `?([A-Za-z0-9_]+)`?/i', $stmt, $m)) $creates[$m[1]] = true;
}
$r['info']['tables'] = count($creates);
$r['info']['statements'] = $count;
foreach ($key as $t) if (!isset($creates[$t])) $r['errors'][] = "Im Datenbank-Abzug fehlt die Tabelle \"{$t}\".";
if (stripos($last, 'FOREIGN_KEY_CHECKS=1') === false) $r['errors'][] = 'Der Datenbank-Abzug ist unvollständig (das Ende fehlt).';
@unlink($tmp);
}
} elseif (isset($names['database/deskhub.sqlite'])) {
// Ältere Backups enthalten die laufende SQLite-Datei als Kopie
$r['info']['driver'] = 'sqlite';
$r['warnings'][] = 'Dieses ältere Backup enthält eine Kopie der laufenden Datenbankdatei. Sie lässt sich nicht garantiert fehlerfrei wiederherstellen.';
$tmp = backup_extract_entry($zip, 'database/deskhub.sqlite');
if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') $r['errors'][] = 'Die Datenbankkopie im Backup ist keine gültige SQLite-Datenbank.';
else $r['errors'][] = 'Backups im alten Format können nicht automatisch wiederhergestellt werden. Bitte ein neues Backup erstellen.';
if ($tmp) @unlink($tmp);
} else {
$r['errors'][] = 'Im Backup fehlt der Datenbank-Abzug.';
}
$zip->close();
$r['ok'] = !$r['errors'];
return $r;
}
/**
* Stellt ein Backup wieder her.
*
* Optionen: database (Standard true), dirs (Liste aus backup_restorable_dirs(), Standard alle),
* root (Zielordner für Dateien), backup_dir (Zielordner des Sicherheits-Backups), safety_backup (Standard true).
*
* @return array{ok:bool, error?:string, tables?:int, rows?:int, files?:int, safety?:string, notes?:string[]}
*/
function backup_restore(PDO $pdo, string $zipPath, array $options = []): array
{
@set_time_limit(600);
$check = backup_verify($zipPath);
if (!$check['ok']) return ['ok' => false, 'error' => 'Das Backup ist nicht wiederherstellbar: ' . implode(' ', $check['errors'])];
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if (($check['info']['driver'] ?? '') !== $driver) {
return ['ok' => false, 'error' => 'Das Backup stammt aus einer ' . strtoupper((string) $check['info']['driver']) . '-Datenbank, dieses System nutzt ' . strtoupper($driver) . '. Eine Wiederherstellung ist nur auf demselben Datenbanktyp möglich.'];
}
$root = realpath($options['root'] ?? __DIR__ . '/..');
$result = ['ok' => true, 'tables' => 0, 'rows' => 0, 'files' => 0, 'notes' => []];
if (($options['safety_backup'] ?? true)) {
$safety = create_backup($root, $options['backup_dir'] ?? null, $pdo);
if (!$safety['ok']) return ['ok' => false, 'error' => 'Das Sicherheits-Backup vor der Wiederherstellung ist fehlgeschlagen, es wurde nichts verändert: ' . $safety['error']];
$result['safety'] = $safety['filename'];
}
$zip = new ZipArchive();
$zip->open($zipPath);
try {
if ($options['database'] ?? true) {
$db = $driver === 'sqlite' ? backup_restore_sqlite($pdo, $zip) : backup_restore_mysql($pdo, $zip);
$result['tables'] = $db['tables'];
$result['rows'] = $db['rows'];
$result['notes'] = array_merge($result['notes'], $db['notes']);
}
$dirs = array_values(array_intersect(array_keys(backup_restorable_dirs()), (array) ($options['dirs'] ?? array_keys(backup_restorable_dirs()))));
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
if (substr($name, -1) === '/' || !backup_entry_name_ok($name)) continue;
$top = explode('/', $name, 2)[0];
if (!in_array($top, $dirs, true)) continue;
$dest = $root . '/' . $name;
if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0755, true);
$in = $zip->getStream($zip->getNameIndex($i));
if (!$in) continue;
$out = fopen($dest, 'wb');
if (!$out) { fclose($in); $result['notes'][] = "Datei nicht beschreibbar: {$name}"; continue; }
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
$result['files']++;
}
} catch (Throwable $e) {
$zip->close();
return ['ok' => false, 'error' => 'Wiederherstellung abgebrochen: ' . $e->getMessage() . (isset($result['safety']) ? ' Der Stand von vorher steht im Sicherheits-Backup ' . $result['safety'] . '.' : '')];
}
$zip->close();
return $result;
}
/** SQLite: Inhalte aller Tabellen des Backups in die laufende Datenbank übernehmen (Spalten, die es in beiden gibt). */
function backup_restore_sqlite(PDO $pdo, ZipArchive $zip): array
{
$tmp = backup_extract_entry($zip, 'database_backup.sqlite');
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
$out = ['tables' => 0, 'rows' => 0, 'notes' => []];
$pdo->exec('PRAGMA foreign_keys = OFF'); // außerhalb einer Transaktion
try {
$pdo->exec('ATTACH DATABASE ' . $pdo->quote($tmp) . ' AS bk');
$pdo->beginTransaction();
$bkTables = $pdo->query("SELECT name FROM bk.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
$liveTables = $pdo->query("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
$cols = fn(string $schema, string $t) => array_column($pdo->query("PRAGMA {$schema}.table_info(\"{$t}\")")->fetchAll(PDO::FETCH_ASSOC), 'name');
$missing = [];
foreach ($liveTables as $t) {
if (!in_array($t, $bkTables, true)) { $missing[] = $t; continue; }
$common = array_values(array_intersect($cols('main', $t), $cols('bk', $t)));
$pdo->exec("DELETE FROM main.\"{$t}\"");
if ($common) {
$list = '"' . implode('","', $common) . '"';
$pdo->exec("INSERT INTO main.\"{$t}\" ({$list}) SELECT {$list} FROM bk.\"{$t}\"");
}
$out['tables']++;
$out['rows'] += (int) $pdo->query("SELECT COUNT(*) FROM main.\"{$t}\"")->fetchColumn();
}
// Zähler der automatischen Nummern übernehmen, damit neue Datensätze nicht alte Nummern wiederverwenden
$hasSeq = (bool) $pdo->query("SELECT 1 FROM bk.sqlite_master WHERE name = 'sqlite_sequence'")->fetchColumn();
if ($hasSeq) {
$pdo->exec('DELETE FROM main.sqlite_sequence');
$pdo->exec('INSERT INTO main.sqlite_sequence (name, seq) SELECT name, seq FROM bk.sqlite_sequence');
}
$pdo->commit();
if ($missing) $out['notes'][] = 'Tabellen, die es im Backup noch nicht gab, blieben unverändert: ' . implode(', ', $missing) . '. Danach unter System-Update „Datenbank aktualisieren“ ausführen.';
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
} finally {
try { $pdo->exec('DETACH DATABASE bk'); } catch (Throwable $e) {}
$pdo->exec('PRAGMA foreign_keys = ON');
@unlink($tmp);
}
return $out;
}
/** MySQL: den SQL-Abzug Anweisung für Anweisung einspielen. */
function backup_restore_mysql(PDO $pdo, ZipArchive $zip): array
{
$tmp = backup_extract_entry($zip, 'database_backup.sql');
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
$out = ['tables' => 0, 'rows' => 0, 'notes' => []];
try {
foreach (backup_sql_statements($tmp) as $stmt) {
$pdo->exec($stmt);
if (preg_match('/^CREATE TABLE/i', $stmt)) $out['tables']++;
if (preg_match('/^INSERT INTO/i', $stmt)) $out['rows'] += max(1, substr_count($stmt, "),\n(") + 1);
}
} finally {
@unlink($tmp);
}
$out['notes'][] = 'Danach unter System-Update „Datenbank aktualisieren“ ausführen, falls das Backup von einer älteren Version stammt.';
return $out;
}
/** Pfad eines Backups im backups-Ordner anhand des Dateinamens (nur echte Backups, sonst null). */
function backup_path_for(string $filename, ?string $backupDir = null): ?string
{
$dir = realpath($backupDir ?? __DIR__ . '/../backups');
$file = basename($filename);
if (!$dir || !preg_match('/^backup_[\d\-_a-f]+\.zip$/', $file) || !is_file($dir . '/' . $file)) return null;
return $dir . '/' . $file;
}
/**
* Prüft ein Backup und merkt sich das Ergebnis (für die Anzeige und den Hinweis im System). Ein Backup, das sich nicht
* wiederherstellen lässt, ist wertlos: Das soll auffallen, bevor man es braucht.
*/
function backup_verify_and_record(string $zipPath): array
{
$r = backup_verify($zipPath);
$now = date('Y-m-d H:i:s');
$wasNotified = !empty((json_decode((string) get_setting('backup_check_map', ''), true) ?: [])[basename($zipPath)]['notified']);
set_setting('backup_last_check', json_encode(['file' => basename($zipPath), 'at' => $now, 'ok' => $r['ok'], 'errors' => $r['errors'], 'info' => $r['info']], JSON_UNESCAPED_UNICODE));
// Ergebnis je Datei (für die Liste auf der Backup-Seite), nur die letzten 40
$map = json_decode((string) get_setting('backup_check_map', ''), true);
$map = is_array($map) ? $map : [];
$map[basename($zipPath)] = ['ok' => (bool) $r['ok'], 'at' => $now, 'notified' => $wasNotified];
// Defektes Backup: Administrator einmal per E-Mail warnen
if (!$r['ok'] && !$wasNotified && function_exists('send_mail')) {
$to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? ''));
if ($to !== '') {
$map[basename($zipPath)]['notified'] = true;
@send_mail($to, 'DeskHub: Backup nicht wiederherstellbar', '<p>Die automatische Prüfung hat ein Problem gefunden:</p><p><strong>' . htmlspecialchars(basename($zipPath)) . '</strong></p><p>' . htmlspecialchars(implode(' ', (array) $r['errors'])) . '</p><p>Erstelle ein neues Backup und prüfe die Einstellungen unter Adminportal → Backup.</p>');
}
}
if (count($map) > 40) $map = array_slice($map, -40, null, true);
set_setting('backup_check_map', json_encode($map));
return $r;
}
/**
* Prüft Backups automatisch nach: solche, die noch nie geprüft wurden, und solche, deren letzte Prüfung über $maxAgeDays Tage her ist
* (um spätere Beschädigungen zu bemerken). Pro Aufruf höchstens $limit Backups, neueste zuerst.
*
* @return array{checked:int,bad:int}
*/
function backup_verify_pending(int $limit = 2, int $maxAgeDays = 7, ?string $backupDir = null): array
{
$dir = realpath($backupDir ?? __DIR__ . '/../backups');
$out = ['checked' => 0, 'bad' => 0];
if (!$dir) return $out;
$map = json_decode((string) get_setting('backup_check_map', ''), true);
$map = is_array($map) ? $map : [];
$files = glob($dir . '/backup_*.zip') ?: [];
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
$stale = time() - $maxAgeDays * 86400;
foreach ($files as $f) {
if ($out['checked'] >= $limit) break;
$name = basename($f);
if (!preg_match('/^backup_[\d\-_a-f]+\.zip$/', $name)) continue;
$entry = $map[$name] ?? null;
if ($entry && strtotime((string) $entry['at']) > $stale && strtotime((string) $entry['at']) >= filemtime($f)) continue; // frisch geprüft
$r = backup_verify_and_record($f);
$out['checked']++;
if (empty($r['ok'])) $out['bad']++;
}
return $out;
}