Upload via GUI (267 Dateien)
This commit is contained in:
@@ -22,9 +22,18 @@ function backup_excluded_fragments(): array
|
||||
* @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
|
||||
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();
|
||||
@@ -93,7 +102,7 @@ Deny from all
|
||||
);
|
||||
|
||||
$files = [];
|
||||
foreach ($iter as $file) {
|
||||
foreach ($withFiles ? $iter : [] as $file) {
|
||||
if (!$file->isFile()) continue;
|
||||
$filePath = $file->getRealPath();
|
||||
$norm = str_replace('\\', '/', $filePath);
|
||||
@@ -106,7 +115,18 @@ Deny from all
|
||||
}
|
||||
if ($liveDb && ($filePath === $liveDb || $filePath === $liveDb . '-wal' || $filePath === $liveDb . '-shm' || $filePath === $liveDb . '-journal')) $skip = true;
|
||||
if ($skip) continue;
|
||||
$files[$filePath] = str_replace(chr(92), '/', substr($filePath, strlen($rootDir) + 1)); // im ZIP immer mit /
|
||||
$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
|
||||
@@ -116,7 +136,7 @@ Deny from all
|
||||
$tableCount = 0;
|
||||
$dbError = null;
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
if ($withDb && $driver === 'sqlite') {
|
||||
$snapshot = tempnam(sys_get_temp_dir(), 'dhbk');
|
||||
@unlink($snapshot);
|
||||
try {
|
||||
@@ -130,7 +150,7 @@ Deny from all
|
||||
if ($sqliteSnapshot) {
|
||||
$tableCount = (int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchColumn();
|
||||
}
|
||||
} elseif ($driver === 'mysql') {
|
||||
} 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";
|
||||
@@ -175,7 +195,7 @@ Deny from all
|
||||
|
||||
$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),
|
||||
'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
|
||||
@@ -191,7 +211,32 @@ Deny from all
|
||||
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
|
||||
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
|
||||
$zip->addFromString('backup_manifest.json', $manifest);
|
||||
$zip->close();
|
||||
$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);
|
||||
@@ -213,11 +258,21 @@ Deny from all
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,298 +1,343 @@
|
||||
<?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);
|
||||
set_setting('backup_last_check', json_encode(['file' => basename($zipPath), 'at' => date('Y-m-d H:i:s'), 'ok' => $r['ok'], 'errors' => $r['errors'], 'info' => $r['info']], JSON_UNESCAPED_UNICODE));
|
||||
return $r;
|
||||
}
|
||||
<?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;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,38 @@ function config(): array
|
||||
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
|
||||
$config = array_replace_recursive($config, $extra);
|
||||
}
|
||||
// Nur für die Testläufe (tests/run_all.php setzt DESKHUB_TESTS_FORCE_SQLITE): nie die in config/config.php eingetragene Datenbank
|
||||
// (zum Beispiel die Produktiv-MySQL) benutzen, sondern SQLite. In Test-Webservern nur mit einer vom Test gesetzten Datenbankdatei.
|
||||
if (getenv('DESKHUB_TESTS_FORCE_SQLITE') === '1') {
|
||||
$testDb = getenv('DESKHUB_SQLITE_PATH');
|
||||
if (PHP_SAPI === 'cli' || ($testDb !== false && $testDb !== '' && $testDb !== ':memory:')) {
|
||||
$config['db']['driver'] = 'sqlite';
|
||||
$config['db']['sqlite_path'] = $testDb !== false && $testDb !== '' ? $testDb : ':memory:';
|
||||
}
|
||||
}
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Datenbank ist nicht eingerichtet: Mit vorhandenem Installer dorthin weiterleiten, sonst eine verständliche Meldung.
|
||||
* Auf der Kommandozeile (Cron, Skripte) wird ein Fehler geworfen.
|
||||
*/
|
||||
function app_not_installed(): never
|
||||
{
|
||||
$message = 'DeskHub ist noch nicht eingerichtet: Die Datenbank fehlt. Bitte die Installation unter /install/ abschließen.';
|
||||
if (PHP_SAPI === 'cli') throw new RuntimeException($message);
|
||||
$installerOpen = is_file(__DIR__ . '/../install/index.php') && !is_file(__DIR__ . '/../install/.installed');
|
||||
if ($installerOpen && !headers_sent()) {
|
||||
header('Location: /install/');
|
||||
exit;
|
||||
}
|
||||
http_response_code(503);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
exit($message . "
|
||||
");
|
||||
}
|
||||
|
||||
function get_db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
@@ -32,6 +60,13 @@ function get_db(): PDO
|
||||
$dbPath = $cfg['sqlite_path'];
|
||||
$needInit = !file_exists($dbPath) || filesize($dbPath) === 0;
|
||||
|
||||
// Eine SQLite-Datenbank wird nie nebenbei angelegt (z. B. durch den ersten Seitenaufruf mit einer mitgelieferten Konfiguration):
|
||||
// Das macht allein der Installer, wenn SQLite gewählt wurde. Nur Tests und Entwickler, die den Pfad ausdrücklich per
|
||||
// DESKHUB_SQLITE_PATH angeben, dürfen eine neue Datenbank erzeugen lassen.
|
||||
if ($needInit && $dbPath !== ':memory:' && (string) getenv('DESKHUB_SQLITE_PATH') === '') {
|
||||
app_not_installed();
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:' . $dbPath);
|
||||
$pdo->exec('PRAGMA journal_mode = WAL;');
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
+1917
-1702
File diff suppressed because it is too large
Load Diff
@@ -19,14 +19,11 @@ if (get_setting('helpdesk_status', 'online') === 'offline') {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($displayName) ?> – Wartungsmodus</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">
|
||||
<?= site_icon_links() ?>
|
||||
<link rel="manifest" href="/manifest.php">
|
||||
<meta name="theme-color" content="#0f6f6a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
|
||||
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function(){});
|
||||
@@ -66,8 +63,7 @@ if ('serviceWorker' in navigator) {
|
||||
<meta name="theme-color" content="#0f6f6a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
|
||||
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
|
||||
<?= site_icon_links() ?>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function(){});
|
||||
@@ -115,7 +111,7 @@ if ('serviceWorker' in navigator) {
|
||||
<span class="text-brand">Desk<span>Hub</span></span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php if ($logo && $displayName): ?>
|
||||
<?php if ($logo && $displayName && get_setting('logo_hide_name', '0') !== '1'): ?>
|
||||
<span style="font-size:1.15rem; font-weight:700; color:var(--ink); letter-spacing:-.01em"><?= e($displayName) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -117,16 +117,19 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
|
||||
}
|
||||
foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName);
|
||||
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
|
||||
$closedByRule = apply_rule_close($pdo, $ticketId, $ruleResult);
|
||||
|
||||
$d = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
|
||||
$d->execute([$deptId]);
|
||||
$dept = $d->fetch();
|
||||
$forNotify = ['ticket_number' => $number, 'subject' => $subject, 'requester_name' => $name, 'requester_email' => $email,
|
||||
'department_id' => $deptId, 'department_name' => $dept['name'] ?? null];
|
||||
notify_new_ticket_channels($forNotify, $dept['email'] ?? null);
|
||||
notify_staff_new_ticket($forNotify);
|
||||
notify_customer_ticket_created($forNotify);
|
||||
webhook_dispatch($pdo, 'ticket.created', $ticketId);
|
||||
if (!$closedByRule) {
|
||||
notify_new_ticket_channels($forNotify, $dept['email'] ?? null);
|
||||
notify_staff_new_ticket($forNotify);
|
||||
notify_customer_ticket_created($forNotify);
|
||||
webhook_dispatch($pdo, 'ticket.created', $ticketId);
|
||||
}
|
||||
return ['number' => $number, 'id' => $ticketId];
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ function plugin_filter_permission(string $hook): ?string
|
||||
{
|
||||
if ($hook === 'ticket.view.sidebar') return 'ui.sidebar';
|
||||
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
|
||||
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -146,14 +147,18 @@ function plugin_add_filter(string $hook, callable $fn, int $priority = 10): void
|
||||
$state['filters'][$hook][$priority][] = ['fn' => $fn, 'plugin' => $state['current']];
|
||||
}
|
||||
|
||||
/** Menüpunkt im Mitarbeiterbereich. $page ist der Dateiname unter plugins/<slug>/pages/ (ohne .php). */
|
||||
function plugin_register_menu(string $label, string $page, ?string $permission = null): void
|
||||
/**
|
||||
* Menüpunkt im Mitarbeiterbereich. $page ist der Dateiname unter plugins/<slug>/pages/ (ohne .php).
|
||||
* Mit $belongsTo (zum Beispiel 'backup', siehe plugin_parent_pages()) erscheint kein eigener Eintrag in der Seitenleiste: Die Seite wird
|
||||
* stattdessen als Reiter innerhalb dieser Seite angeboten (zum Beispiel unter „Backup“). Dafür ist keine zusätzliche Berechtigung nötig (ui.menu genügt).
|
||||
*/
|
||||
function plugin_register_menu(string $label, string $page, ?string $permission = null, ?string $belongsTo = null): void
|
||||
{
|
||||
if (!plugin_permission_allowed('ui.menu')) return;
|
||||
$state = &plugin_state();
|
||||
$slug = $state['current'];
|
||||
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $page)) return;
|
||||
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission];
|
||||
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission, 'belongs_to' => ($belongsTo !== null && isset(plugin_parent_pages()[$belongsTo])) ? $belongsTo : null];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,11 +311,45 @@ function plugin_menu_items(?array $staff): array
|
||||
$items = [];
|
||||
foreach (plugin_state()['menus'] as $m) {
|
||||
if ($m['permission'] !== null && (!$staff || !has_permission($staff, $m['permission']))) continue;
|
||||
if (!empty($m['belongs_to'])) continue; // erscheint als Reiter in der übergeordneten Seite, nicht in der Seitenleiste
|
||||
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/** Seiten, unter denen Plugins Reiter anbieten dürfen: Schlüssel => [Bezeichnung des ersten Reiters, Adresse]. */
|
||||
function plugin_parent_pages(): array
|
||||
{
|
||||
return ['backup' => ['Backups', '/staff/backup.php']];
|
||||
}
|
||||
|
||||
/** Plugin-Seiten (mit Rechteprüfung), die als Reiter zu einer übergeordneten Seite gehören. */
|
||||
function plugin_subpages(string $parent, ?array $staff): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (plugin_state()['menus'] as $m) {
|
||||
if (($m['belongs_to'] ?? null) !== $parent) continue;
|
||||
if ($m['permission'] !== null && (!$staff || !has_permission($staff, $m['permission']))) continue;
|
||||
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/** Reiterleiste einer übergeordneten Seite (leer, wenn kein Plugin Reiter anbietet). $active: 'parent' oder '<plugin>:<seite>'. */
|
||||
function plugin_subpage_tabs(string $parent, ?array $staff, string $active = 'parent'): string
|
||||
{
|
||||
$parents = plugin_parent_pages();
|
||||
$subs = plugin_subpages($parent, $staff);
|
||||
if (!$subs || !isset($parents[$parent])) return '';
|
||||
$tab = function (string $label, string $url, bool $on): string {
|
||||
return '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '" style="padding:8px 16px;border-radius:8px 8px 0 0;text-decoration:none;font-size:.9rem;'
|
||||
. ($on ? 'background:var(--card-bg,#fff);border:1px solid var(--border);border-bottom-color:var(--card-bg,#fff);font-weight:600;color:inherit;position:relative;top:1px' : 'color:var(--muted);border:1px solid transparent') . '">' . htmlspecialchars($label) . '</a>';
|
||||
};
|
||||
$html = '<div class="plugin-tabs" style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border);margin:0 0 18px">' . $tab($parents[$parent][0], $parents[$parent][1], $active === 'parent');
|
||||
foreach ($subs as $s) $html .= $tab($s['label'], $s['url'], $active === $s['plugin'] . ':' . $s['page']);
|
||||
return $html . '</div>';
|
||||
}
|
||||
|
||||
// ─── Verwaltung (Installieren, Aktivieren, Entfernen) ────────────────────────
|
||||
|
||||
function plugins_installed(PDO $pdo): array
|
||||
@@ -785,10 +824,13 @@ function plugin_run_migrations(PDO $pdo, string $slug): array
|
||||
if (in_array($key, $done, true)) continue;
|
||||
$sql = preg_replace('/^\s*--.*$/m', '', (string) file_get_contents($file));
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
// SQLite kann Tabellen-Änderungen in einer Transaktion zurückrollen. MySQL beendet bei CREATE/ALTER TABLE jede offene
|
||||
// Transaktion von selbst ("There is no active transaction"), dort läuft die Datei deshalb ohne Transaktion.
|
||||
$useTransaction = $driver === 'sqlite';
|
||||
if ($useTransaction) $pdo->beginTransaction();
|
||||
foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) $pdo->exec($stmt);
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute([$key]);
|
||||
$pdo->commit();
|
||||
if ($useTransaction && $pdo->inTransaction()) $pdo->commit();
|
||||
$applied++;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
|
||||
@@ -96,6 +96,16 @@ function pseudo_cron_dispatch(): void
|
||||
catalog_refresh_background();
|
||||
}
|
||||
|
||||
// Backups automatisch prüfen: neue/ungeprüfte sofort, geprüfte nach 7 Tagen erneut (höchstens alle 10 Minuten, 2 je Lauf)
|
||||
if (pseudo_cron_try_claim($pdo, 'cron_verify_backups_last_run', 600)) {
|
||||
try {
|
||||
if (!function_exists('backup_verify_pending')) require_once __DIR__ . '/backup_restore.php';
|
||||
backup_verify_pending(2);
|
||||
} catch (Throwable $e) {
|
||||
error_log('pseudo_cron [verify_backups]: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Plugins: wiederkehrende Aufgaben (höchstens alle 5 Minuten)
|
||||
if (pseudo_cron_try_claim($pdo, 'plugins_cron_last_run', 300)) {
|
||||
plugin_hook('cron.run', ['time' => time()]);
|
||||
|
||||
@@ -124,7 +124,7 @@ function security_check_settings(PDO $pdo): array
|
||||
|
||||
// Reverse-Proxy ohne Konfiguration: alle Besucher teilen sich eine IP (gemeinsame Login-Sperre)
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && !request_from_trusted_proxy()) {
|
||||
$out[] = ['level' => 'warn', 'title' => 'Reverse-Proxy nicht eingetragen', 'detail' => 'Die Anfrage kam über einen Proxy (X-Forwarded-For), aber dessen Adresse ist nicht als vertrauenswürdig eingetragen. DeskHub sieht dadurch für alle Besucher dieselbe IP: Anmelde-Sperren treffen dann alle gleichzeitig. Eintragen unter site → trusted_proxies in config/config.php (Adresse oder Bereich des Proxys, z. B. \'172.16.0.0/12\').'];
|
||||
$out[] = ['level' => 'warn', 'title' => 'Reverse-Proxy nicht eingetragen', 'detail' => 'Die Anfrage kam über einen Proxy (X-Forwarded-For), aber dessen Adresse (' . e((string) ($_SERVER['REMOTE_ADDR'] ?? '?')) . ') ist nicht als vertrauenswürdig eingetragen. DeskHub sieht dadurch für alle Besucher dieselbe IP: Anmelde-Sperren treffen dann alle gleichzeitig. Eintragen unter site → trusted_proxies in config/config.php (Adresse oder Bereich des Proxys, z. B. \'172.16.0.0/12\').'];
|
||||
}
|
||||
|
||||
// Fehlerausgabe
|
||||
|
||||
@@ -59,9 +59,25 @@ function update_find_assets(array $release, string $tag): array
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugangstoken für den Update-Server (nur nötig, wenn das Repository privat ist; Gitea antwortet sonst mit "404").
|
||||
* Quelle: config/config.php ('update' => ['token' => '…']) hat Vorrang, sonst das in den Einstellungen der Update-Seite
|
||||
* gespeicherte (verschlüsselt). Gibt [Token, Quelle] zurück, Quelle 'config', 'einstellungen' oder ''.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function update_token_info(): array
|
||||
{
|
||||
$fromConfig = trim((string) (config()['update']['token'] ?? ''));
|
||||
if ($fromConfig !== '') return [$fromConfig, 'config'];
|
||||
$stored = (string) get_setting('update_token', '');
|
||||
$fromSettings = $stored !== '' ? trim(decrypt_secret($stored)) : '';
|
||||
return $fromSettings !== '' ? [$fromSettings, 'einstellungen'] : ['', ''];
|
||||
}
|
||||
|
||||
/** Kopfzeilen für Abrufe beim Update-Server; ein Zugangstoken ist nur nötig, wenn das Repository nicht öffentlich ist. */
|
||||
function update_http_headers(): array
|
||||
{
|
||||
$token = (string) (config()['update']['token'] ?? '');
|
||||
[$token] = update_token_info();
|
||||
return $token !== '' ? ['Authorization: token ' . $token] : [];
|
||||
}
|
||||
|
||||
@@ -9,12 +9,82 @@
|
||||
* Sicherheitsgründen vom Server gelöscht oder zumindest gesperrt werden.
|
||||
*/
|
||||
|
||||
// Sicherheits-Header und Sitzungs-Cookie des Assistenten
|
||||
header('X-Frame-Options: DENY');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
$installHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0])) === 'https';
|
||||
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $installHttps, 'httponly' => true, 'samesite' => 'Lax']);
|
||||
session_start();
|
||||
|
||||
define('ROOT_DIR', dirname(__DIR__));
|
||||
define('LOCK_FILE', __DIR__ . '/.installed');
|
||||
require_once ROOT_DIR . '/database/sqlite_schema.php';
|
||||
|
||||
/** Gleiche Regeln wie im Programm (validate_password_strength), damit das Admin-Konto nicht schwächer ist als alle anderen. */
|
||||
function install_password_error(string $p): ?string
|
||||
{
|
||||
if (strlen($p) < 10) return 'Das Admin-Passwort muss mindestens 10 Zeichen lang sein.';
|
||||
if (!preg_match('/[A-Z]/', $p)) return 'Das Admin-Passwort muss mindestens einen Großbuchstaben enthalten.';
|
||||
if (!preg_match('/[a-z]/', $p)) return 'Das Admin-Passwort muss mindestens einen Kleinbuchstaben enthalten.';
|
||||
if (!preg_match('/[0-9]/', $p)) return 'Das Admin-Passwort muss mindestens eine Ziffer enthalten.';
|
||||
if (!preg_match('/[^A-Za-z0-9]/', $p)) return 'Das Admin-Passwort muss mindestens ein Sonderzeichen enthalten.';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Verständliche Meldung zu einem Datenbankfehler (mit dem technischen Text dahinter). */
|
||||
function install_db_error(Throwable $e): string
|
||||
{
|
||||
$code = (int) ($e instanceof PDOException ? ($e->errorInfo[1] ?? $e->getCode()) : 0);
|
||||
$hint = match (true) {
|
||||
$code === 1045 => 'Benutzername oder Passwort der Datenbank stimmen nicht.',
|
||||
$code === 1049 => 'Die Datenbank existiert nicht. Legen Sie sie zuerst (leer) an, z. B. im Hosting-Bereich.',
|
||||
$code === 1044 => 'Der Datenbank-Benutzer hat keine Rechte auf diese Datenbank.',
|
||||
$code === 2002 || $code === 2006 => 'Der Datenbank-Server ist nicht erreichbar. Prüfen Sie Host (und Port, z. B. localhost:3306).',
|
||||
$code === 2054 => 'Der Datenbank-Server verlangt eine Anmeldemethode, die diese PHP-Version nicht unterstützt.',
|
||||
default => 'Prüfen Sie Host, Datenbankname, Benutzer und Passwort.',
|
||||
};
|
||||
return 'Datenbankverbindung fehlgeschlagen. ' . $hint . ' (Technisch: ' . $e->getMessage() . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Konfiguration so, dass sie sicher gilt: auch wenn schon eine (alte, z. B. mitgelieferte) config.php liegt,
|
||||
* die dem Webserver nicht gehört oder nicht beschreibbar ist, und mit Löschen des PHP-Zwischenspeichers (OPcache),
|
||||
* damit nicht weiter die alte Version geladen wird.
|
||||
*/
|
||||
function install_write_config(string $path, string $content): bool
|
||||
{
|
||||
$ok = @file_put_contents($path, $content) !== false;
|
||||
if (!$ok && is_file($path)) {
|
||||
@chmod($path, 0644);
|
||||
$ok = @file_put_contents($path, $content) !== false;
|
||||
if (!$ok && @unlink($path)) $ok = @file_put_contents($path, $content) !== false; // Datei gehört einem anderen Benutzer, der Ordner aber nicht
|
||||
}
|
||||
if ($ok) {
|
||||
clearstatcache(true, $path);
|
||||
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/** Liest die geschriebene Konfiguration frisch ein und prüft, dass genau die eingegebenen Daten darin stehen. Gibt null oder eine Fehlermeldung zurück. */
|
||||
function install_verify_config(string $path, array $expected): ?string
|
||||
{
|
||||
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
|
||||
$onDisk = (string) @file_get_contents($path);
|
||||
if (!str_contains($onDisk, var_export((string) $expected['app_key'], true))) return 'Die Konfigurationsdatei enthält nicht die neuen Daten. Prüfen Sie die Schreibrechte auf config/config.php.';
|
||||
$loaded = @include $path;
|
||||
$db = is_array($loaded) ? (array) ($loaded['db'] ?? []) : [];
|
||||
foreach (['driver', 'host', 'name', 'user', 'pass'] as $k) {
|
||||
if ((string) ($db[$k] ?? '') !== (string) $expected['db'][$k]) {
|
||||
return 'Die Konfiguration wurde geschrieben, PHP liefert aber noch eine ältere Version (Zwischenspeicher/OPcache des Servers). Laden Sie PHP neu (Hosting-Oberfläche) oder warten Sie einige Minuten und starten Sie die Installation erneut.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function h(?string $s): string
|
||||
{
|
||||
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
||||
@@ -22,7 +92,7 @@ function h(?string $s): string
|
||||
|
||||
// URL der aktuellen Installation automatisch erkennen
|
||||
$detectedUrl = (function(): string {
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$scheme = $GLOBALS['installHttps'] ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
|
||||
// install/ vom Pfad entfernen → Root-URL
|
||||
$path = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
|
||||
@@ -55,22 +125,33 @@ $existingInstallation = (function (): bool {
|
||||
$alreadyInstalled = file_exists(LOCK_FILE) || $existingInstallation;
|
||||
$errors = [];
|
||||
$success = false;
|
||||
$manualConfig = null;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Anforderungsprüfung
|
||||
// ---------------------------------------------------------------
|
||||
// Ordner anlegen, die für den Betrieb nötig sind (falls beim Hochladen leer weggelassen)
|
||||
foreach (['uploads', 'backups', 'plugins', 'app_downloads', 'config'] as $__dir) {
|
||||
if (!is_dir(ROOT_DIR . '/' . $__dir)) @mkdir(ROOT_DIR . '/' . $__dir, 0755, true);
|
||||
}
|
||||
$checks = [
|
||||
['label' => 'PHP-Version 8.1 oder neuer (gefunden: ' . PHP_VERSION . ')', 'ok' => version_compare(PHP_VERSION, '8.1.0', '>=')],
|
||||
['label' => 'PHP-Version 8.2 oder neuer (gefunden: ' . PHP_VERSION . ')', 'ok' => version_compare(PHP_VERSION, '8.2.0', '>=')],
|
||||
['label' => 'PDO-Erweiterung', 'ok' => extension_loaded('pdo')],
|
||||
['label' => 'PDO MySQL-Treiber (für Produktivbetrieb)', 'ok' => extension_loaded('pdo_mysql'), 'optional' => true],
|
||||
['label' => 'PDO SQLite-Treiber (optional, für lokale Tests)', 'ok' => extension_loaded('pdo_sqlite'), 'optional' => true],
|
||||
['label' => 'Mindestens ein Datenbanktreiber (MySQL oder SQLite)', 'ok' => extension_loaded('pdo_mysql') || extension_loaded('pdo_sqlite')],
|
||||
['label' => 'mbstring-Erweiterung', 'ok' => extension_loaded('mbstring')],
|
||||
['label' => 'openssl-Erweiterung (Verschlüsselung der Zugangsdaten, Signaturen)', 'ok' => extension_loaded('openssl')],
|
||||
['label' => 'json-Erweiterung', 'ok' => extension_loaded('json')],
|
||||
['label' => 'curl-Erweiterung (optional: Webhooks, Plugin-Katalog, Updates)', 'ok' => extension_loaded('curl'), 'optional' => true],
|
||||
['label' => 'zip-Erweiterung (optional: Plugins, Themes, Update per ZIP, Backups)', 'ok' => extension_loaded('zip'), 'optional' => true],
|
||||
['label' => 'IMAP-Erweiterung (optional, nur für E-Mail-zu-Ticket)', 'ok' => extension_loaded('imap'), 'optional' => true],
|
||||
['label' => 'Verzeichnis config/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/config')],
|
||||
['label' => 'Verzeichnis uploads/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/uploads')],
|
||||
['label' => 'Verzeichnis database/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/database')],
|
||||
['label' => 'Verzeichnis database/ ist beschreibbar (nur für SQLite nötig)', 'ok' => is_writable(ROOT_DIR . '/database'), 'optional' => true],
|
||||
['label' => 'Verzeichnis backups/ ist beschreibbar (für Backups)', 'ok' => is_writable(ROOT_DIR . '/backups'), 'optional' => true],
|
||||
['label' => 'HTTPS (empfohlen: Anmeldedaten sonst unverschlüsselt)', 'ok' => $installHttps, 'optional' => true],
|
||||
];
|
||||
|
||||
$requiredOk = true;
|
||||
foreach ($checks as $c) {
|
||||
if (empty($c['optional']) && !$c['ok']) {
|
||||
@@ -97,7 +178,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
|
||||
if ($siteUrl === '') $errors[] = 'Bitte die URL der Installation angeben (z.B. https://support.firma.de).';
|
||||
if ($adminName === '') $errors[] = 'Bitte einen Namen für das Admin-Konto angeben.';
|
||||
if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige Admin-E-Mail-Adresse angeben.';
|
||||
if (strlen($adminPass) < 8) $errors[] = 'Das Admin-Passwort muss mindestens 8 Zeichen lang sein.';
|
||||
if (($pwError = install_password_error($adminPass)) !== null) $errors[] = $pwError;
|
||||
if ($adminPass !== $adminPass2) $errors[] = 'Die beiden Passwörter stimmen nicht überein.';
|
||||
if (!extension_loaded('pdo_' . $driver)) $errors[] = "Die PHP-Erweiterung pdo_{$driver} ist nicht installiert.";
|
||||
|
||||
@@ -127,13 +208,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
|
||||
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $dbConfig['host'], $dbConfig['name']);
|
||||
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass']);
|
||||
} else {
|
||||
// Nur bei gewähltem SQLite wird eine Datenbank-Datei angelegt, und nur für diese Installation:
|
||||
// Bricht sie ab, wird die halbfertige Datei am Ende des Aufrufs wieder entfernt.
|
||||
@unlink($dbConfig['sqlite_path']);
|
||||
$sqlitePath = $dbConfig['sqlite_path'];
|
||||
register_shutdown_function(function () use (&$success, &$pdo, &$stmt, $sqlitePath) {
|
||||
if ($success) return;
|
||||
// Verbindung und Statements schließen, sonst lässt sich die Datei (vor allem unter Windows) nicht löschen
|
||||
$stmt = null;
|
||||
$pdo = null;
|
||||
gc_collect_cycles();
|
||||
foreach ([$sqlitePath, $sqlitePath . '-wal', $sqlitePath . '-shm'] as $leftover) @unlink($leftover);
|
||||
});
|
||||
$pdo = new PDO('sqlite:' . $dbConfig['sqlite_path']);
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
}
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = 'Datenbankverbindung fehlgeschlagen: ' . $e->getMessage();
|
||||
$errors[] = install_db_error($e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +239,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
|
||||
|
||||
// Vordefiniertes Demo-Admin-Konto entfernen und durch das hier
|
||||
// eingegebene, echte Admin-Konto ersetzen.
|
||||
$pdo->exec("DELETE FROM staff WHERE email = 'admin@example.com'");
|
||||
$pdo->prepare('DELETE FROM staff WHERE email IN (?, ?)')->execute(['admin@example.com', $adminEmail]);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'
|
||||
);
|
||||
@@ -240,11 +332,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
|
||||
. " * WICHTIG: app_key niemals ändern – verschlüsselte Werte in der DB werden sonst unlesbar.\n"
|
||||
. " */\n\nreturn " . var_export($configArray, true) . ";\n";
|
||||
|
||||
if (file_put_contents(ROOT_DIR . '/config/config.php', $php) === false) {
|
||||
$errors[] = 'config/config.php konnte nicht geschrieben werden. Bitte Schreibrechte des Verzeichnisses prüfen.';
|
||||
$cfgPath = ROOT_DIR . '/config/config.php';
|
||||
if (!install_write_config($cfgPath, $php)) {
|
||||
$errors[] = 'config/config.php konnte nicht geschrieben werden. Die Datenbank ist eingerichtet. Speichern Sie den unten angezeigten Inhalt als config/config.php auf dem Server (oder geben Sie dem Webserver Schreibrechte auf den Ordner config/) und laden Sie die Seite neu.';
|
||||
$manualConfig = $php;
|
||||
} else {
|
||||
file_put_contents(LOCK_FILE, date('Y-m-d H:i:s') . " von " . ($_SERVER['REMOTE_ADDR'] ?? 'CLI'));
|
||||
$success = true;
|
||||
@chmod($cfgPath, 0640); // enthält Schlüssel und Datenbank-Passwort
|
||||
if (($verifyError = install_verify_config($cfgPath, $configArray)) !== null) {
|
||||
$errors[] = $verifyError;
|
||||
$manualConfig = $php;
|
||||
} else {
|
||||
// Programm-Migrationen (Schritte mit Code statt SQL) jetzt mit der fertigen Konfiguration einspielen: Fehler erscheinen hier
|
||||
// und nicht erst beim ersten Seitenaufruf. Scheitert das, wird die Konfiguration wieder entfernt, damit ein neuer Versuch möglich ist.
|
||||
try {
|
||||
require_once ROOT_DIR . '/includes/db.php';
|
||||
require_once ROOT_DIR . '/includes/functions.php';
|
||||
get_db();
|
||||
@file_put_contents(LOCK_FILE, date('Y-m-d H:i:s') . ' von ' . ($_SERVER['REMOTE_ADDR'] ?? 'CLI'));
|
||||
$success = true;
|
||||
} catch (Throwable $e) {
|
||||
@unlink($cfgPath);
|
||||
$errors[] = 'Fehler beim Einspielen der Datenbank-Änderungen: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,7 +601,7 @@ html, body {
|
||||
box-shadow: 0 0 0 3px rgba(15,111,106,.12);
|
||||
}
|
||||
.i-field input::placeholder { color: #b0bac6; }
|
||||
.i-field .i-hint {
|
||||
.i-hint, .i-field .i-hint {
|
||||
font-size: .74rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
@@ -715,9 +825,11 @@ code {
|
||||
<div class="i-card" style="text-align:center;padding:40px 28px">
|
||||
<div class="i-success-icon">🔒</div>
|
||||
<h2 style="margin-bottom:10px">Bereits installiert</h2>
|
||||
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">
|
||||
Eingerichtet am <strong><?= h(is_file(LOCK_FILE) ? (string) file_get_contents(LOCK_FILE) : "bereits vorhandener Konfiguration") ?></strong>
|
||||
</p>
|
||||
<?php if (is_file(LOCK_FILE)): ?>
|
||||
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">Eingerichtet am <strong><?= h((string) file_get_contents(LOCK_FILE)) ?></strong></p>
|
||||
<?php else: ?>
|
||||
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">Es gibt bereits eine Konfiguration mit Datenbank und Mitarbeiterkonten. Eine Neuinstallation würde diese Daten überschreiben und ist deshalb gesperrt.</p>
|
||||
<?php endif; ?>
|
||||
<div class="i-alert warning" style="margin-top:20px;text-align:left">
|
||||
<span class="i-alert-icon">⚠️</span>
|
||||
<span>Bitte das Verzeichnis <code>install/</code> vom Server löschen, um diese Seite zu deaktivieren.</span>
|
||||
@@ -726,8 +838,8 @@ code {
|
||||
<a href="/login.php" class="i-btn-primary">Kundenportal</a>
|
||||
<a href="/staff/login.php" class="i-btn-secondary">Mitarbeiter-Login</a>
|
||||
</div>
|
||||
<p style="font-size:.75rem;color:var(--muted);margin-top:16px">
|
||||
Zum Wiederholen: <code>install/.installed</code> löschen
|
||||
<p style="font-size:.75rem;color:var(--muted);margin-top:16px;line-height:1.6">
|
||||
Wirklich neu installieren? Erst ein Backup der Daten anlegen, dann <code>config/config.php</code>, <code>install/.installed</code> und die Datenbank (bei SQLite die Datei <code>database/deskhub.sqlite</code>) entfernen. Alle bisherigen Daten gehen dabei verloren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -779,6 +891,13 @@ code {
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="i-alert error"><span class="i-alert-icon">✗</span><span><?= h($err) ?></span></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($manualConfig !== null): ?>
|
||||
<div class="i-card">
|
||||
<div class="i-card-title">Inhalt für config/config.php</div>
|
||||
<div class="i-hint" style="margin-bottom:10px">Enthält Schlüssel und Datenbank-Passwort: nur auf dem Server speichern, nirgends weitergeben.</div>
|
||||
<textarea readonly rows="14" style="width:100%;font-family:monospace;font-size:.78rem" onclick="this.select()"><?= h($manualConfig) ?></textarea>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Step 2: Configuration form -->
|
||||
<div class="i-card">
|
||||
@@ -825,7 +944,7 @@ code {
|
||||
<input type="password" id="db_pass" name="db_pass" placeholder="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
<p class="i-hint" style="margin-bottom:16px">Die Datenbank muss bereits existieren (leer) — Tabellen werden automatisch angelegt.</p>
|
||||
<div class="i-hint" style="margin-bottom:16px">Die Datenbank muss bereits existieren (leer). Tabellen werden automatisch angelegt.</div>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
@@ -849,7 +968,7 @@ code {
|
||||
</div>
|
||||
<div class="i-field">
|
||||
<label for="admin_email">E-Mail</label>
|
||||
<input type="email" id="admin_email" name="admin_email" value="<?= h($_POST['admin_email'] ?? '') ?>" placeholder="admin@firma.de" required>
|
||||
<input type="email" id="admin_email" name="admin_email" autocapitalize="none" autocorrect="off" spellcheck="false" value="<?= h($_POST['admin_email'] ?? '') ?>" placeholder="admin@firma.de" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="i-row">
|
||||
@@ -857,6 +976,7 @@ code {
|
||||
<label for="admin_password">Passwort</label>
|
||||
<input type="password" id="admin_password" name="admin_password" placeholder="••••••••" required>
|
||||
<div class="i-pw-bar"><div class="i-pw-fill" id="pw-fill"></div></div>
|
||||
<div class="i-hint">Mindestens 10 Zeichen mit Groß- und Kleinbuchstaben, Ziffer und Sonderzeichen.</div>
|
||||
</div>
|
||||
<div class="i-field">
|
||||
<label for="admin_password2">Passwort wiederholen</label>
|
||||
@@ -888,17 +1008,24 @@ document.querySelectorAll('.i-driver-tab input[type=radio]').forEach(function(ra
|
||||
});
|
||||
});
|
||||
|
||||
// Doppelklick verhindern: sonst startet die Installation zweimal
|
||||
var installForm = document.getElementById('install-form');
|
||||
if (installForm) installForm.addEventListener('submit', function() {
|
||||
var b = installForm.querySelector('button[type=submit]');
|
||||
if (b) { b.disabled = true; b.textContent = 'Wird installiert …'; }
|
||||
});
|
||||
|
||||
// Password strength bar
|
||||
var pwInput = document.getElementById('admin_password');
|
||||
var pwFill = document.getElementById('pw-fill');
|
||||
if (pwInput && pwFill) {
|
||||
pwInput.addEventListener('input', function() {
|
||||
var v = this.value, score = 0;
|
||||
if (v.length >= 8) score++;
|
||||
if (v.length >= 12) score++;
|
||||
if (/[A-Z]/.test(v)) score++;
|
||||
if (v.length >= 10) score++;
|
||||
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++;
|
||||
if (/[0-9]/.test(v)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(v)) score++;
|
||||
if (v.length >= 14) score++;
|
||||
var colors = ['#ef4444','#f97316','#eab308','#22c55e','#16a34a'];
|
||||
var widths = ['20%','40%','60%','80%','100%'];
|
||||
pwFill.style.width = score > 0 ? widths[score-1] : '0%';
|
||||
|
||||
@@ -59,7 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
usort($files, fn($a,$b) => filemtime($b)-filemtime($a));
|
||||
foreach (array_slice($files, $keep) as $old) @unlink($old);
|
||||
}
|
||||
$verified = backup_verify_and_record($result['path']);
|
||||
$verified = $result['verify'] ?? backup_verify_and_record($result['path']); // create_backup() hat schon automatisch geprüft
|
||||
audit_log($staff['id'], "Manuelles Backup: {$result['filename']} ({$result['size']} MB)$ftpMsg");
|
||||
flash($verified['ok'] ? 'success' : 'error', ($verified['ok'] ? "✅ Backup erstellt und geprüft: {$result['filename']} ({$result['size']} MB){$ftpMsg}"
|
||||
: "Backup erstellt, aber NICHT wiederherstellbar: " . implode(' ', $verified['errors'])));
|
||||
@@ -164,7 +164,7 @@ $backupDir = realpath(__DIR__ . '/../backups') ?: (dirname(__DIR__) . '/backup
|
||||
$backupFiles = [];
|
||||
if (is_dir($backupDir)) {
|
||||
foreach (array_merge(glob($backupDir.'/backup_*.zip')?:[], glob($backupDir.'/backup_*.tar.gz')?:[]) as $f) {
|
||||
$backupFiles[] = ['name'=>basename($f),'size'=>round(filesize($f)/1024/1024,2),'date'=>filemtime($f)];
|
||||
$backupFiles[] = ['name'=>basename($f),'size'=>round(filesize($f)/1024/1024,2),'bytes'=>filesize($f),'date'=>filemtime($f)];
|
||||
}
|
||||
usort($backupFiles, fn($a,$b) => $b['date']-$a['date']);
|
||||
}
|
||||
@@ -172,11 +172,35 @@ $totalSize = array_sum(array_column($backupFiles, 'size'));
|
||||
$lastAutoRun = get_setting('cron_process_backups_last_run');
|
||||
$lastAutoBkp = bget('last_auto_backup');
|
||||
|
||||
$checkMap = json_decode((string) get_setting('backup_check_map', ''), true);
|
||||
$checkMap = is_array($checkMap) ? $checkMap : [];
|
||||
$rows = [];
|
||||
foreach ($backupFiles as $b) $rows[] = $b + ['source' => 'local', 'badges' => [], 'actions' => ''];
|
||||
// Plugins mit Reiter unter „Backup“ (zum Beispiel Synology NAS Backup) können Marken, Knöpfe und weitere Einträge (nur extern vorhandene Backups) ergänzen.
|
||||
// Zeilen: name, size (MB), bytes, date, source ('local'|'extern'), badges (HTML-Liste), actions (HTML). Die Plugins sind für das Maskieren ihrer HTML-Ausgaben selbst verantwortlich.
|
||||
if (function_exists('plugin_filter')) $rows = plugin_filter('backup.list.rows', $rows, ['backupDir' => $backupDir]);
|
||||
usort($rows, fn($a, $b) => ($b['date'] ?? 0) <=> ($a['date'] ?? 0));
|
||||
$ago = function (int $ts): string {
|
||||
$d = time() - $ts;
|
||||
if ($d < 90) return 'gerade eben';
|
||||
if ($d < 3600) return 'vor ' . round($d / 60) . ' Min.';
|
||||
if ($d < 86400) return 'vor ' . round($d / 3600) . ' Std.';
|
||||
return 'vor ' . round($d / 86400) . ' Tagen';
|
||||
};
|
||||
$fmtMb = fn($mb) => $mb >= 1024 ? number_format($mb / 1024, 2, ',', '.') . ' GB' : number_format((float) $mb, $mb < 10 ? 2 : 0, ',', '.') . ' MB';
|
||||
|
||||
// Zahlen für die Kacheln oben: Backups auf dem Server und (über Plugins, zum Beispiel NAS) extern vorhandene
|
||||
$externRows = array_values(array_filter($rows, fn($r) => ($r['source'] ?? 'local') !== 'local'));
|
||||
$sizeAll = array_sum(array_map(fn($r) => (float) ($r['size'] ?? 0), $rows));
|
||||
$sizeExtern = array_sum(array_map(fn($r) => (float) ($r['size'] ?? 0), $externRows));
|
||||
$newestTs = $rows ? (int) max(array_map(fn($r) => (int) ($r['date'] ?? 0), $rows)) : 0;
|
||||
|
||||
$pageTitle = 'Backup';
|
||||
$activePage = 'backup';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?= plugin_subpage_tabs('backup', $staff, 'parent') ?>
|
||||
<div class="topbar">
|
||||
<h1>💾 Backup</h1>
|
||||
<form method="post" id="backup-form">
|
||||
@@ -188,37 +212,80 @@ require __DIR__ . '/_header.php';
|
||||
|
||||
<!-- Statistiken -->
|
||||
<div class="stat-grid" style="margin-bottom:20px">
|
||||
<div class="stat-card"><div class="num"><?= count($backupFiles) ?></div><div class="lbl">Backups vorhanden</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $totalSize ?> MB</div><div class="lbl">Gesamtgröße</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $backupFiles ? date('d.m.Y', $backupFiles[0]['date']) : '–' ?></div><div class="lbl">Letztes Backup</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $lastAutoBkp ? date('d.m.Y', strtotime($lastAutoBkp)) : '–' ?></div><div class="lbl">Letztes Auto-Backup</div></div>
|
||||
<div class="stat-card"><div class="num"><?= count($rows) ?></div><div class="lbl">Backups vorhanden</div>
|
||||
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px"><?= count($backupFiles) ?> auf dem Server<?= $externRows ? ' · ' . count($externRows) . ' nur extern (NAS)' : '' ?></div></div>
|
||||
<div class="stat-card"><div class="num"><?= e($fmtMb($sizeAll)) ?></div><div class="lbl">Gesamtgröße</div>
|
||||
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px">Server: <?= e($fmtMb($totalSize)) ?><?= $externRows ? ' · extern: ' . e($fmtMb($sizeExtern)) : '' ?></div></div>
|
||||
<div class="stat-card"><div class="num"><?= $newestTs ? e(date('d.m.Y', $newestTs)) : '–' ?></div><div class="lbl">Letztes Backup</div>
|
||||
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px"><?= $newestTs ? e(date('H:i', $newestTs)) . ' Uhr' : 'noch keins' ?></div></div>
|
||||
<div class="stat-card"><div class="num"><?= $lastAutoBkp ? date('d.m.Y', strtotime($lastAutoBkp)) : '–' ?></div><div class="lbl">Letztes Auto-Backup</div>
|
||||
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px"><?= $lastAutoBkp ? e(date('H:i', strtotime($lastAutoBkp))) . ' Uhr' : ((bget('schedule') ?: 'disabled') === 'disabled' ? 'Zeitplan aus' : 'noch nicht gelaufen') ?></div></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bk-list { display:flex; flex-direction:column; }
|
||||
.bk-row { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:6px 16px; align-items:center; padding:9px 4px; border-top:1px solid var(--border); }
|
||||
.bk-row:first-child { border-top:0; }
|
||||
.bk-name { min-width:0; }
|
||||
.bk-name code { font-size:.82rem; word-break:break-all; }
|
||||
.bk-meta { display:block; font-size:.76rem; color:var(--muted); margin-top:1px; }
|
||||
.bk-badges { display:flex; gap:5px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.bk-badge { font-size:.7rem; padding:2px 8px; border-radius:999px; border:1px solid var(--border); color:var(--muted); white-space:nowrap; }
|
||||
.bk-badge.ok { color:#15803d; border-color:#bbf7d0; background:#f0fdf4; }
|
||||
.bk-badge.bad { color:#b91c1c; border-color:#fecaca; background:#fef2f2; }
|
||||
.bk-actions { display:flex; gap:5px; align-items:center; justify-content:flex-end; flex-wrap:nowrap; }
|
||||
.bk-actions form { margin:0; }
|
||||
.bk-btn { padding:4px 9px; font-size:.76rem; line-height:1.4; white-space:nowrap; cursor:pointer; list-style:none; }
|
||||
.bk-btn::-webkit-details-marker { display:none; }
|
||||
.bk-restore { position:relative; }
|
||||
.bk-restore form.bk-pop { position:absolute; right:0; z-index:5; width:340px; padding:14px; background:var(--card-bg,#fff); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.18); text-align:left; }
|
||||
@media (max-width:820px) { .bk-row { grid-template-columns:1fr; } .bk-badges, .bk-actions { justify-content:flex-start; flex-wrap:wrap; } }
|
||||
</style>
|
||||
|
||||
<!-- Backup-Liste -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<h3>Vorhandene Backups</h3>
|
||||
<?php $lastCheck = json_decode((string) get_setting('backup_last_check', ''), true); if (is_array($lastCheck)): ?>
|
||||
<p class="muted" style="font-size:.82rem; margin-top:-6px">Letzte Prüfung: <?= e(date('d.m.Y H:i', strtotime($lastCheck['at']))) ?> – <?= e($lastCheck['file']) ?>:
|
||||
<strong style="color:<?= $lastCheck['ok'] ? '#15803d' : '#dc2626' ?>"><?= $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?></strong></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!$backupFiles): ?>
|
||||
<div style="display:flex; justify-content:space-between; align-items:baseline; gap:12px; flex-wrap:wrap">
|
||||
<h3 style="margin:0">Vorhandene Backups</h3>
|
||||
<?php $lastCheck = json_decode((string) get_setting('backup_last_check', ''), true); if (is_array($lastCheck)): ?>
|
||||
<span class="muted" style="font-size:.8rem">Letzte Prüfung: <?= e(date('d.m.Y H:i', strtotime($lastCheck['at']))) ?> –
|
||||
<strong style="color:<?= $lastCheck['ok'] ? '#15803d' : '#dc2626' ?>"><?= $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?></strong></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!$rows): ?>
|
||||
<div class="empty-state">Noch keine Backups vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th><th style="width:160px"></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($backupFiles as $b): ?>
|
||||
<tr>
|
||||
<td style="font-family:monospace; font-size:.82rem"><?= e($b['name']) ?></td>
|
||||
<td class="muted"><?= $b['size'] ?> MB</td>
|
||||
<td class="muted"><?= date('d.m.Y H:i', $b['date']) ?></td>
|
||||
<td style="display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start">
|
||||
<a href="/staff/backup_download.php?file=<?= urlencode($b['name']) ?>" class="btn secondary" style="padding:5px 10px; font-size:.78rem">⬇️ Download</a>
|
||||
<div class="bk-list" style="margin-top:8px">
|
||||
<?php foreach ($rows as $b):
|
||||
$isLocal = ($b['source'] ?? 'local') === 'local';
|
||||
$chk = $isLocal ? ($checkMap[$b['name']] ?? null) : null;
|
||||
?>
|
||||
<div class="bk-row">
|
||||
<div class="bk-name">
|
||||
<code><?= e($b['name']) ?></code>
|
||||
<span class="bk-meta"><?= e($fmtMb($b['size'] ?? 0)) ?> · <?= e(date('d.m.Y H:i', (int) ($b['date'] ?? 0))) ?> (<?= e($ago((int) ($b['date'] ?? 0))) ?>)<?= !$isLocal ? ' · nicht auf diesem Server' : '' ?></span>
|
||||
</div>
|
||||
<div class="bk-badges">
|
||||
<?php if ($isLocal && str_ends_with($b['name'], '.zip')): ?>
|
||||
<?php if ($chk): ?>
|
||||
<span class="bk-badge <?= $chk['ok'] ? 'ok' : 'bad' ?>" title="Geprüft am <?= e(date('d.m.Y H:i', strtotime($chk['at']))) ?>"><?= $chk['ok'] ? '✔ geprüft' : '✖ defekt' ?></span>
|
||||
<?php else: ?>
|
||||
<span class="bk-badge" title="Noch nicht auf Wiederherstellbarkeit geprüft">nicht geprüft</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php foreach ((array) ($b['badges'] ?? []) as $badge): ?><?= $badge ?><?php endforeach; ?>
|
||||
</div>
|
||||
<div class="bk-actions">
|
||||
<?php if ($isLocal): ?>
|
||||
<a href="/staff/backup_download.php?file=<?= urlencode($b['name']) ?>" class="btn secondary bk-btn" title="Backup herunterladen">⬇ Download</a>
|
||||
<?php if (str_ends_with($b['name'], '.zip')): ?>
|
||||
<form method="post" style="margin:0"><?= csrf_field() ?><input type="hidden" name="action" value="verify_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
|
||||
<button type="submit" class="btn secondary" style="padding:5px 10px; font-size:.78rem" title="Prüft, ob sich das Backup wiederherstellen lässt (verändert nichts)">✔️ Prüfen</button></form>
|
||||
<details style="position:relative"><summary class="btn secondary" style="padding:5px 10px; font-size:.78rem; list-style:none; cursor:pointer">♻️ Wiederherstellen</summary>
|
||||
<form method="post" style="position:absolute; right:0; z-index:5; width:340px; padding:14px; background:var(--card-bg,#fff); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.18)">
|
||||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="verify_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
|
||||
<button type="submit" class="btn secondary bk-btn" title="Prüft, ob sich das Backup wiederherstellen lässt (verändert nichts)">✔ Prüfen</button></form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?= $b['actions'] ?? '' ?>
|
||||
<?php if ($isLocal && str_ends_with($b['name'], '.zip')): ?>
|
||||
<details class="bk-restore"><summary class="btn secondary bk-btn" title="Datenbank und Dateien aus diesem Backup zurückspielen">♻ Wiederherstellen</summary>
|
||||
<form method="post" class="bk-pop">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="restore_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
|
||||
<p style="font-size:.82rem; margin:0 0 8px"><strong>Achtung:</strong> Der aktuelle Stand der Datenbank wird durch den Stand dieses Backups ersetzt. Vorher wird automatisch ein Sicherheits-Backup erstellt. Programmdateien und die Konfiguration bleiben unverändert.</p>
|
||||
<label style="display:block; font-weight:400"><input type="checkbox" name="database" value="1" checked style="width:auto"> Datenbank</label>
|
||||
@@ -227,21 +294,21 @@ require __DIR__ . '/_header.php';
|
||||
<input type="text" name="confirm" autocomplete="off" required>
|
||||
<button type="submit" class="btn" style="margin-top:8px; background:var(--danger)">Jetzt wiederherstellen</button>
|
||||
</form></details>
|
||||
<?php endif; ?>
|
||||
<form method="post" onsubmit="return confirm('Backup löschen?')" style="margin:0">
|
||||
<?php endif; ?>
|
||||
<?php if ($isLocal): ?>
|
||||
<form method="post" onsubmit="return confirm('Dieses Backup auf dem Server löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_backup">
|
||||
<input type="hidden" name="filename" value="<?= e($b['name']) ?>">
|
||||
<button type="submit" class="btn secondary" style="padding:5px 10px; font-size:.78rem; border-color:var(--danger); color:var(--danger)">🗑️</button>
|
||||
<button type="submit" class="btn secondary bk-btn" style="border-color:var(--danger); color:var(--danger)" title="Auf dem Server löschen">🗑</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:20px">
|
||||
|
||||
<!-- Zeitplan -->
|
||||
@@ -251,6 +318,7 @@ require __DIR__ . '/_header.php';
|
||||
Cron-Job einrichten: <code>php <?= e(dirname(__DIR__)) ?>/cron/process_backups.php</code><br>
|
||||
<?php if ($lastAutoRun): ?><span style="font-size:.78rem">Letzter Lauf: <?= format_dt($lastAutoRun) ?></span><?php endif; ?>
|
||||
</p>
|
||||
<p class="muted" style="font-size:.8rem; margin:0 0 12px">✔ Jedes Backup wird nach dem Erstellen automatisch auf Wiederherstellbarkeit geprüft; ältere werden regelmäßig erneut geprüft.</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_schedule">
|
||||
|
||||
Reference in New Issue
Block a user