'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', '

Die automatische Prüfung hat ein Problem gefunden:

' . htmlspecialchars(basename($zipPath)) . '

' . htmlspecialchars(implode(' ', (array) $r['errors'])) . '

Erstelle ein neues Backup und prüfe die Einstellungen unter Adminportal → Backup.

'); } } 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; }