Upload via GUI (267 Dateien)

This commit is contained in:
2026-10-19 18:05:57 +00:00
parent f3e53e6182
commit c800be9693
12 changed files with 2694 additions and 2082 deletions
+62 -7
View File
@@ -22,9 +22,18 @@ function backup_excluded_fragments(): array
* @param string|null $rootDir Ordner, dessen Dateien gesichert werden (Standard: DeskHub-Hauptordner) * @param string|null $rootDir Ordner, dessen Dateien gesichert werden (Standard: DeskHub-Hauptordner)
* @param string|null $backupDir Zielordner (Standard: backups/ im Hauptordner) * @param string|null $backupDir Zielordner (Standard: backups/ im Hauptordner)
* @param PDO|null $pdo Datenbankverbindung (Standard: get_db()) * @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__ . '/..'); $rootDir = realpath($rootDir ?? __DIR__ . '/..');
$backupDir = $backupDir ?? $rootDir . '/backups'; $backupDir = $backupDir ?? $rootDir . '/backups';
$pdo = $pdo ?? get_db(); $pdo = $pdo ?? get_db();
@@ -93,7 +102,7 @@ Deny from all
); );
$files = []; $files = [];
foreach ($iter as $file) { foreach ($withFiles ? $iter : [] as $file) {
if (!$file->isFile()) continue; if (!$file->isFile()) continue;
$filePath = $file->getRealPath(); $filePath = $file->getRealPath();
$norm = str_replace('\\', '/', $filePath); $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 ($liveDb && ($filePath === $liveDb || $filePath === $liveDb . '-wal' || $filePath === $liveDb . '-shm' || $filePath === $liveDb . '-journal')) $skip = true;
if ($skip) continue; 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 // Abzug erzeugen
@@ -116,7 +136,7 @@ Deny from all
$tableCount = 0; $tableCount = 0;
$dbError = null; $dbError = null;
if ($driver === 'sqlite') { if ($withDb && $driver === 'sqlite') {
$snapshot = tempnam(sys_get_temp_dir(), 'dhbk'); $snapshot = tempnam(sys_get_temp_dir(), 'dhbk');
@unlink($snapshot); @unlink($snapshot);
try { try {
@@ -130,7 +150,7 @@ Deny from all
if ($sqliteSnapshot) { if ($sqliteSnapshot) {
$tableCount = (int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchColumn(); $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 { try {
$dbName = (string) $pdo->query('SELECT DATABASE()')->fetchColumn(); $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"; $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([ $manifest = json_encode([
'created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null, '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); ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
// ZIP erstellen – ZipArchive wenn verfügbar, sonst eigene Implementierung // ZIP erstellen – ZipArchive wenn verfügbar, sonst eigene Implementierung
@@ -191,7 +211,32 @@ Deny from all
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump); if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite'); if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
$zip->addFromString('backup_manifest.json', $manifest); $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 { } else {
// Eigene ZIP-Implementierung (kein php_zip nötig) // Eigene ZIP-Implementierung (kein php_zip nötig)
$zip = new SimpleZip($backupPath); $zip = new SimpleZip($backupPath);
@@ -213,11 +258,21 @@ Deny from all
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt.']; 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 [ return [
'ok' => true, 'ok' => true,
'filename' => $backupName, 'filename' => $backupName,
'path' => $backupPath, 'path' => $backupPath,
'files' => count($files), 'files' => count($files),
'skipped' => $skipped,
'verify' => $verify,
'size' => round(filesize($backupPath) / 1024 / 1024, 2), 'size' => round(filesize($backupPath) / 1024 / 1024, 2),
]; ];
} }
+343 -298
View File
@@ -1,298 +1,343 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* Backups prüfen und wiederherstellen (Gegenstück zu create_backup in includes/backup.php). * 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_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 * 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 * 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/. * die Datenbank sowie die Ordner uploads/, plugins/ und app_downloads/.
*/ */
require_once __DIR__ . '/backup.php'; require_once __DIR__ . '/backup.php';
/** Ordner im Backup, die beim Wiederherstellen zurückgespielt werden dürfen. */ /** Ordner im Backup, die beim Wiederherstellen zurückgespielt werden dürfen. */
function backup_restorable_dirs(): array function backup_restorable_dirs(): array
{ {
return ['uploads' => 'Hochgeladene Dateien (Anhänge)', 'plugins' => 'Plugins', 'app_downloads' => 'App-Dateien']; return ['uploads' => 'Hochgeladene Dateien (Anhänge)', 'plugins' => 'Plugins', 'app_downloads' => 'App-Dateien'];
} }
/** Ist der Eintragsname im ZIP unbedenklich (keine .., keine absoluten Pfade)? */ /** Ist der Eintragsname im ZIP unbedenklich (keine .., keine absoluten Pfade)? */
function backup_entry_name_ok(string $name): bool function backup_entry_name_ok(string $name): bool
{ {
$n = str_replace('\\', '/', $name); $n = str_replace('\\', '/', $name);
return $n !== '' && $n[0] !== '/' && !str_contains($n, "\0") && !preg_match('#(^|/)\.\.(/|$)#', $n) && !preg_match('#^[A-Za-z]:#', $n); 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). */ /** Zerlegt einen SQL-Dump in Anweisungen (Zeilenweise, mit Beachtung von Zeichenketten). */
function backup_sql_statements(string $file): Generator function backup_sql_statements(string $file): Generator
{ {
$fh = fopen($file, 'rb'); $fh = fopen($file, 'rb');
$buf = ''; $buf = '';
$inQuote = false; $inQuote = false;
while (($line = fgets($fh)) !== false) { while (($line = fgets($fh)) !== false) {
if (!$inQuote && $buf === '' && (trim($line) === '' || str_starts_with($line, '--'))) continue; if (!$inQuote && $buf === '' && (trim($line) === '' || str_starts_with($line, '--'))) continue;
$buf .= $line; $buf .= $line;
// Anführungszeichen-Zustand über die Zeile fortschreiben // Anführungszeichen-Zustand über die Zeile fortschreiben
$n = strlen($line); $n = strlen($line);
for ($i = 0; $i < $n; $i++) { for ($i = 0; $i < $n; $i++) {
$c = $line[$i]; $c = $line[$i];
if ($inQuote) { if ($inQuote) {
if ($c === '\\') { $i++; continue; } if ($c === '\\') { $i++; continue; }
if ($c === "'") $inQuote = false; if ($c === "'") $inQuote = false;
} elseif ($c === "'") { } elseif ($c === "'") {
$inQuote = true; $inQuote = true;
} }
} }
if (!$inQuote && substr(rtrim($line), -1) === ';') { if (!$inQuote && substr(rtrim($line), -1) === ';') {
yield trim($buf); yield trim($buf);
$buf = ''; $buf = '';
} }
} }
if (trim($buf) !== '') yield trim($buf); if (trim($buf) !== '') yield trim($buf);
fclose($fh); fclose($fh);
} }
/** Entpackt einen Eintrag in eine temporäre Datei (Streaming, auch für große Dateien). */ /** Entpackt einen Eintrag in eine temporäre Datei (Streaming, auch für große Dateien). */
function backup_extract_entry(ZipArchive $zip, string $name): ?string function backup_extract_entry(ZipArchive $zip, string $name): ?string
{ {
$in = $zip->getStream($name); $in = $zip->getStream($name);
if (!$in) return null; if (!$in) return null;
$tmp = tempnam(sys_get_temp_dir(), 'dhrs'); $tmp = tempnam(sys_get_temp_dir(), 'dhrs');
$out = fopen($tmp, 'wb'); $out = fopen($tmp, 'wb');
stream_copy_to_stream($in, $out); stream_copy_to_stream($in, $out);
fclose($in); fclose($in);
fclose($out); fclose($out);
return $tmp; return $tmp;
} }
/** /**
* Prüft ein Backup, ohne etwas zu verändern. * Prüft ein Backup, ohne etwas zu verändern.
* *
* @return array{ok:bool, errors:string[], warnings:string[], info:array} * @return array{ok:bool, errors:string[], warnings:string[], info:array}
*/ */
function backup_verify(string $zipPath): array function backup_verify(string $zipPath): array
{ {
$r = ['ok' => false, 'errors' => [], 'warnings' => [], 'info' => []]; $r = ['ok' => false, 'errors' => [], 'warnings' => [], 'info' => []];
if (!class_exists('ZipArchive')) { $r['errors'][] = 'Die PHP-Erweiterung zip fehlt.'; return $r; } 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; } if (!is_file($zipPath) || filesize($zipPath) < 100) { $r['errors'][] = 'Die Backup-Datei fehlt oder ist leer.'; return $r; }
$zip = new ZipArchive(); $zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CHECKCONS) !== true) { $r['errors'][] = 'Die ZIP-Datei ist beschädigt oder unvollständig.'; return $r; } if ($zip->open($zipPath, ZipArchive::CHECKCONS) !== true) { $r['errors'][] = 'Die ZIP-Datei ist beschädigt oder unvollständig.'; return $r; }
$names = []; $names = [];
for ($i = 0; $i < $zip->numFiles; $i++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$n = (string) $zip->getNameIndex($i); $n = (string) $zip->getNameIndex($i);
if (!backup_entry_name_ok($n)) { $zip->close(); $r['errors'][] = "Unzulässiger Pfad im Backup: {$n}"; return $r; } if (!backup_entry_name_ok($n)) { $zip->close(); $r['errors'][] = "Unzulässiger Pfad im Backup: {$n}"; return $r; }
$names[$n] = true; $names[$n] = true;
} }
$r['info']['files'] = count($names); $r['info']['files'] = count($names);
$manifest = null; $manifest = null;
if (isset($names['backup_manifest.json'])) { if (isset($names['backup_manifest.json'])) {
$manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true); $manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true);
if (!is_array($manifest)) { $r['errors'][] = 'Das Manifest im Backup ist unlesbar.'; $manifest = null; } if (!is_array($manifest)) { $r['errors'][] = 'Das Manifest im Backup ist unlesbar.'; $manifest = null; }
} }
$r['info']['created'] = $manifest['created'] ?? null; $r['info']['created'] = $manifest['created'] ?? null;
$r['info']['version'] = $manifest['version'] ?? null; $r['info']['version'] = $manifest['version'] ?? null;
$key = ['tickets', 'users', 'staff', 'settings']; $key = ['tickets', 'users', 'staff', 'settings'];
if (isset($names['database_backup.sqlite'])) { if (isset($names['database_backup.sqlite'])) {
$r['info']['driver'] = 'sqlite'; $r['info']['driver'] = 'sqlite';
$tmp = backup_extract_entry($zip, 'database_backup.sqlite'); $tmp = backup_extract_entry($zip, 'database_backup.sqlite');
if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') { 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.'; $r['errors'][] = 'Der Datenbank-Abzug im Backup ist keine gültige SQLite-Datenbank.';
} else { } else {
try { try {
$db = new PDO('sqlite:' . $tmp); $db = new PDO('sqlite:' . $tmp);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$integrity = $db->query('PRAGMA integrity_check')->fetchColumn(); $integrity = $db->query('PRAGMA integrity_check')->fetchColumn();
if ($integrity !== 'ok') $r['errors'][] = 'Die Datenbank im Backup ist beschädigt: ' . $integrity; 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); $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); $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 ($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(); 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) { } catch (Throwable $e) {
$r['errors'][] = 'Die Datenbank im Backup lässt sich nicht lesen: ' . $e->getMessage(); $r['errors'][] = 'Die Datenbank im Backup lässt sich nicht lesen: ' . $e->getMessage();
} }
$db = null; $db = null;
} }
if ($tmp) @unlink($tmp); if ($tmp) @unlink($tmp);
} elseif (isset($names['database_backup.sql'])) { } elseif (isset($names['database_backup.sql'])) {
$r['info']['driver'] = 'mysql'; $r['info']['driver'] = 'mysql';
$tmp = backup_extract_entry($zip, 'database_backup.sql'); $tmp = backup_extract_entry($zip, 'database_backup.sql');
if (!$tmp) { if (!$tmp) {
$r['errors'][] = 'Der Datenbank-Abzug im Backup ist nicht lesbar.'; $r['errors'][] = 'Der Datenbank-Abzug im Backup ist nicht lesbar.';
} else { } else {
$creates = []; $creates = [];
$count = 0; $count = 0;
$last = ''; $last = '';
foreach (backup_sql_statements($tmp) as $stmt) { foreach (backup_sql_statements($tmp) as $stmt) {
$count++; $count++;
$last = $stmt; $last = $stmt;
if (preg_match('/^CREATE TABLE `?([A-Za-z0-9_]+)`?/i', $stmt, $m)) $creates[$m[1]] = true; if (preg_match('/^CREATE TABLE `?([A-Za-z0-9_]+)`?/i', $stmt, $m)) $creates[$m[1]] = true;
} }
$r['info']['tables'] = count($creates); $r['info']['tables'] = count($creates);
$r['info']['statements'] = $count; $r['info']['statements'] = $count;
foreach ($key as $t) if (!isset($creates[$t])) $r['errors'][] = "Im Datenbank-Abzug fehlt die Tabelle \"{$t}\"."; 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).'; if (stripos($last, 'FOREIGN_KEY_CHECKS=1') === false) $r['errors'][] = 'Der Datenbank-Abzug ist unvollständig (das Ende fehlt).';
@unlink($tmp); @unlink($tmp);
} }
} elseif (isset($names['database/deskhub.sqlite'])) { } elseif (isset($names['database/deskhub.sqlite'])) {
// Ältere Backups enthalten die laufende SQLite-Datei als Kopie // Ältere Backups enthalten die laufende SQLite-Datei als Kopie
$r['info']['driver'] = 'sqlite'; $r['info']['driver'] = 'sqlite';
$r['warnings'][] = 'Dieses ältere Backup enthält eine Kopie der laufenden Datenbankdatei. Sie lässt sich nicht garantiert fehlerfrei wiederherstellen.'; $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'); $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.'; 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.'; else $r['errors'][] = 'Backups im alten Format können nicht automatisch wiederhergestellt werden. Bitte ein neues Backup erstellen.';
if ($tmp) @unlink($tmp); if ($tmp) @unlink($tmp);
} else { } else {
$r['errors'][] = 'Im Backup fehlt der Datenbank-Abzug.'; $r['errors'][] = 'Im Backup fehlt der Datenbank-Abzug.';
} }
$zip->close(); $zip->close();
$r['ok'] = !$r['errors']; $r['ok'] = !$r['errors'];
return $r; return $r;
} }
/** /**
* Stellt ein Backup wieder her. * Stellt ein Backup wieder her.
* *
* Optionen: database (Standard true), dirs (Liste aus backup_restorable_dirs(), Standard alle), * 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). * 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[]} * @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 function backup_restore(PDO $pdo, string $zipPath, array $options = []): array
{ {
@set_time_limit(600); @set_time_limit(600);
$check = backup_verify($zipPath); $check = backup_verify($zipPath);
if (!$check['ok']) return ['ok' => false, 'error' => 'Das Backup ist nicht wiederherstellbar: ' . implode(' ', $check['errors'])]; if (!$check['ok']) return ['ok' => false, 'error' => 'Das Backup ist nicht wiederherstellbar: ' . implode(' ', $check['errors'])];
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if (($check['info']['driver'] ?? '') !== $driver) { 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.']; 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__ . '/..'); $root = realpath($options['root'] ?? __DIR__ . '/..');
$result = ['ok' => true, 'tables' => 0, 'rows' => 0, 'files' => 0, 'notes' => []]; $result = ['ok' => true, 'tables' => 0, 'rows' => 0, 'files' => 0, 'notes' => []];
if (($options['safety_backup'] ?? true)) { if (($options['safety_backup'] ?? true)) {
$safety = create_backup($root, $options['backup_dir'] ?? null, $pdo); $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']]; 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']; $result['safety'] = $safety['filename'];
} }
$zip = new ZipArchive(); $zip = new ZipArchive();
$zip->open($zipPath); $zip->open($zipPath);
try { try {
if ($options['database'] ?? true) { if ($options['database'] ?? true) {
$db = $driver === 'sqlite' ? backup_restore_sqlite($pdo, $zip) : backup_restore_mysql($pdo, $zip); $db = $driver === 'sqlite' ? backup_restore_sqlite($pdo, $zip) : backup_restore_mysql($pdo, $zip);
$result['tables'] = $db['tables']; $result['tables'] = $db['tables'];
$result['rows'] = $db['rows']; $result['rows'] = $db['rows'];
$result['notes'] = array_merge($result['notes'], $db['notes']); $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())))); $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++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$name = str_replace('\\', '/', (string) $zip->getNameIndex($i)); $name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
if (substr($name, -1) === '/' || !backup_entry_name_ok($name)) continue; if (substr($name, -1) === '/' || !backup_entry_name_ok($name)) continue;
$top = explode('/', $name, 2)[0]; $top = explode('/', $name, 2)[0];
if (!in_array($top, $dirs, true)) continue; if (!in_array($top, $dirs, true)) continue;
$dest = $root . '/' . $name; $dest = $root . '/' . $name;
if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0755, true); if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0755, true);
$in = $zip->getStream($zip->getNameIndex($i)); $in = $zip->getStream($zip->getNameIndex($i));
if (!$in) continue; if (!$in) continue;
$out = fopen($dest, 'wb'); $out = fopen($dest, 'wb');
if (!$out) { fclose($in); $result['notes'][] = "Datei nicht beschreibbar: {$name}"; continue; } if (!$out) { fclose($in); $result['notes'][] = "Datei nicht beschreibbar: {$name}"; continue; }
stream_copy_to_stream($in, $out); stream_copy_to_stream($in, $out);
fclose($in); fclose($in);
fclose($out); fclose($out);
$result['files']++; $result['files']++;
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$zip->close(); $zip->close();
return ['ok' => false, 'error' => 'Wiederherstellung abgebrochen: ' . $e->getMessage() . (isset($result['safety']) ? ' Der Stand von vorher steht im Sicherheits-Backup ' . $result['safety'] . '.' : '')]; return ['ok' => false, 'error' => 'Wiederherstellung abgebrochen: ' . $e->getMessage() . (isset($result['safety']) ? ' Der Stand von vorher steht im Sicherheits-Backup ' . $result['safety'] . '.' : '')];
} }
$zip->close(); $zip->close();
return $result; return $result;
} }
/** SQLite: Inhalte aller Tabellen des Backups in die laufende Datenbank übernehmen (Spalten, die es in beiden gibt). */ /** 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 function backup_restore_sqlite(PDO $pdo, ZipArchive $zip): array
{ {
$tmp = backup_extract_entry($zip, 'database_backup.sqlite'); $tmp = backup_extract_entry($zip, 'database_backup.sqlite');
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.'); if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
$out = ['tables' => 0, 'rows' => 0, 'notes' => []]; $out = ['tables' => 0, 'rows' => 0, 'notes' => []];
$pdo->exec('PRAGMA foreign_keys = OFF'); // außerhalb einer Transaktion $pdo->exec('PRAGMA foreign_keys = OFF'); // außerhalb einer Transaktion
try { try {
$pdo->exec('ATTACH DATABASE ' . $pdo->quote($tmp) . ' AS bk'); $pdo->exec('ATTACH DATABASE ' . $pdo->quote($tmp) . ' AS bk');
$pdo->beginTransaction(); $pdo->beginTransaction();
$bkTables = $pdo->query("SELECT name FROM bk.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN); $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); $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'); $cols = fn(string $schema, string $t) => array_column($pdo->query("PRAGMA {$schema}.table_info(\"{$t}\")")->fetchAll(PDO::FETCH_ASSOC), 'name');
$missing = []; $missing = [];
foreach ($liveTables as $t) { foreach ($liveTables as $t) {
if (!in_array($t, $bkTables, true)) { $missing[] = $t; continue; } if (!in_array($t, $bkTables, true)) { $missing[] = $t; continue; }
$common = array_values(array_intersect($cols('main', $t), $cols('bk', $t))); $common = array_values(array_intersect($cols('main', $t), $cols('bk', $t)));
$pdo->exec("DELETE FROM main.\"{$t}\""); $pdo->exec("DELETE FROM main.\"{$t}\"");
if ($common) { if ($common) {
$list = '"' . implode('","', $common) . '"'; $list = '"' . implode('","', $common) . '"';
$pdo->exec("INSERT INTO main.\"{$t}\" ({$list}) SELECT {$list} FROM bk.\"{$t}\""); $pdo->exec("INSERT INTO main.\"{$t}\" ({$list}) SELECT {$list} FROM bk.\"{$t}\"");
} }
$out['tables']++; $out['tables']++;
$out['rows'] += (int) $pdo->query("SELECT COUNT(*) FROM main.\"{$t}\"")->fetchColumn(); $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 // 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(); $hasSeq = (bool) $pdo->query("SELECT 1 FROM bk.sqlite_master WHERE name = 'sqlite_sequence'")->fetchColumn();
if ($hasSeq) { if ($hasSeq) {
$pdo->exec('DELETE FROM main.sqlite_sequence'); $pdo->exec('DELETE FROM main.sqlite_sequence');
$pdo->exec('INSERT INTO main.sqlite_sequence (name, seq) SELECT name, seq FROM bk.sqlite_sequence'); $pdo->exec('INSERT INTO main.sqlite_sequence (name, seq) SELECT name, seq FROM bk.sqlite_sequence');
} }
$pdo->commit(); $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.'; 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) { } catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack(); if ($pdo->inTransaction()) $pdo->rollBack();
throw $e; throw $e;
} finally { } finally {
try { $pdo->exec('DETACH DATABASE bk'); } catch (Throwable $e) {} try { $pdo->exec('DETACH DATABASE bk'); } catch (Throwable $e) {}
$pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA foreign_keys = ON');
@unlink($tmp); @unlink($tmp);
} }
return $out; return $out;
} }
/** MySQL: den SQL-Abzug Anweisung für Anweisung einspielen. */ /** MySQL: den SQL-Abzug Anweisung für Anweisung einspielen. */
function backup_restore_mysql(PDO $pdo, ZipArchive $zip): array function backup_restore_mysql(PDO $pdo, ZipArchive $zip): array
{ {
$tmp = backup_extract_entry($zip, 'database_backup.sql'); $tmp = backup_extract_entry($zip, 'database_backup.sql');
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.'); if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
$out = ['tables' => 0, 'rows' => 0, 'notes' => []]; $out = ['tables' => 0, 'rows' => 0, 'notes' => []];
try { try {
foreach (backup_sql_statements($tmp) as $stmt) { foreach (backup_sql_statements($tmp) as $stmt) {
$pdo->exec($stmt); $pdo->exec($stmt);
if (preg_match('/^CREATE TABLE/i', $stmt)) $out['tables']++; 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); if (preg_match('/^INSERT INTO/i', $stmt)) $out['rows'] += max(1, substr_count($stmt, "),\n(") + 1);
} }
} finally { } finally {
@unlink($tmp); @unlink($tmp);
} }
$out['notes'][] = 'Danach unter System-Update „Datenbank aktualisieren“ ausführen, falls das Backup von einer älteren Version stammt.'; $out['notes'][] = 'Danach unter System-Update „Datenbank aktualisieren“ ausführen, falls das Backup von einer älteren Version stammt.';
return $out; return $out;
} }
/** Pfad eines Backups im backups-Ordner anhand des Dateinamens (nur echte Backups, sonst null). */ /** Pfad eines Backups im backups-Ordner anhand des Dateinamens (nur echte Backups, sonst null). */
function backup_path_for(string $filename, ?string $backupDir = null): ?string function backup_path_for(string $filename, ?string $backupDir = null): ?string
{ {
$dir = realpath($backupDir ?? __DIR__ . '/../backups'); $dir = realpath($backupDir ?? __DIR__ . '/../backups');
$file = basename($filename); $file = basename($filename);
if (!$dir || !preg_match('/^backup_[\d\-_a-f]+\.zip$/', $file) || !is_file($dir . '/' . $file)) return null; if (!$dir || !preg_match('/^backup_[\d\-_a-f]+\.zip$/', $file) || !is_file($dir . '/' . $file)) return null;
return $dir . '/' . $file; 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 * 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. * wiederherstellen lässt, ist wertlos: Das soll auffallen, bevor man es braucht.
*/ */
function backup_verify_and_record(string $zipPath): array function backup_verify_and_record(string $zipPath): array
{ {
$r = backup_verify($zipPath); $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)); $now = date('Y-m-d H:i:s');
return $r; $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;
}
+35
View File
@@ -15,10 +15,38 @@ function config(): array
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) { if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
$config = array_replace_recursive($config, $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; 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 function get_db(): PDO
{ {
static $pdo = null; static $pdo = null;
@@ -32,6 +60,13 @@ function get_db(): PDO
$dbPath = $cfg['sqlite_path']; $dbPath = $cfg['sqlite_path'];
$needInit = !file_exists($dbPath) || filesize($dbPath) === 0; $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 = new PDO('sqlite:' . $dbPath);
$pdo->exec('PRAGMA journal_mode = WAL;'); $pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA foreign_keys = ON;'); $pdo->exec('PRAGMA foreign_keys = ON;');
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"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($displayName) ?> – Wartungsmodus</title> <title><?= e($displayName) ?> – Wartungsmodus</title>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>"> <link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<link rel="icon" href="/favicon.ico" sizes="any"> <?= site_icon_links() ?>
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">
<link rel="manifest" href="/manifest.php"> <link rel="manifest" href="/manifest.php">
<meta name="theme-color" content="#0f6f6a"> <meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <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> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){}); navigator.serviceWorker.register('/sw.js').catch(function(){});
@@ -66,8 +63,7 @@ if ('serviceWorker' in navigator) {
<meta name="theme-color" content="#0f6f6a"> <meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png"> <?= site_icon_links() ?>
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){}); navigator.serviceWorker.register('/sw.js').catch(function(){});
@@ -115,7 +111,7 @@ if ('serviceWorker' in navigator) {
<span class="text-brand">Desk<span>Hub</span></span> <span class="text-brand">Desk<span>Hub</span></span>
<?php endif; ?> <?php endif; ?>
</a> </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> <span style="font-size:1.15rem; font-weight:700; color:var(--ink); letter-spacing:-.01em"><?= e($displayName) ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
+7 -4
View File
@@ -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); foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName);
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']); 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 = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$d->execute([$deptId]); $d->execute([$deptId]);
$dept = $d->fetch(); $dept = $d->fetch();
$forNotify = ['ticket_number' => $number, 'subject' => $subject, 'requester_name' => $name, 'requester_email' => $email, $forNotify = ['ticket_number' => $number, 'subject' => $subject, 'requester_name' => $name, 'requester_email' => $email,
'department_id' => $deptId, 'department_name' => $dept['name'] ?? null]; 'department_id' => $deptId, 'department_name' => $dept['name'] ?? null];
notify_new_ticket_channels($forNotify, $dept['email'] ?? null); if (!$closedByRule) {
notify_staff_new_ticket($forNotify); notify_new_ticket_channels($forNotify, $dept['email'] ?? null);
notify_customer_ticket_created($forNotify); notify_staff_new_ticket($forNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId); notify_customer_ticket_created($forNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId);
}
return ['number' => $number, 'id' => $ticketId]; return ['number' => $number, 'id' => $ticketId];
} }
+47 -5
View File
@@ -127,6 +127,7 @@ function plugin_filter_permission(string $hook): ?string
{ {
if ($hook === 'ticket.view.sidebar') return 'ui.sidebar'; if ($hook === 'ticket.view.sidebar') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets'; 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; 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']]; $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; if (!plugin_permission_allowed('ui.menu')) return;
$state = &plugin_state(); $state = &plugin_state();
$slug = $state['current']; $slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $page)) return; 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 = []; $items = [];
foreach (plugin_state()['menus'] as $m) { foreach (plugin_state()['menus'] as $m) {
if ($m['permission'] !== null && (!$staff || !has_permission($staff, $m['permission']))) continue; 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'])]; $items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
} }
return $items; 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) ──────────────────────── // ─── Verwaltung (Installieren, Aktivieren, Entfernen) ────────────────────────
function plugins_installed(PDO $pdo): array 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; if (in_array($key, $done, true)) continue;
$sql = preg_replace('/^\s*--.*$/m', '', (string) file_get_contents($file)); $sql = preg_replace('/^\s*--.*$/m', '', (string) file_get_contents($file));
try { 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); foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) $pdo->exec($stmt);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute([$key]); $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute([$key]);
$pdo->commit(); if ($useTransaction && $pdo->inTransaction()) $pdo->commit();
$applied++; $applied++;
} catch (Throwable $e) { } catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack(); if ($pdo->inTransaction()) $pdo->rollBack();
+10
View File
@@ -96,6 +96,16 @@ function pseudo_cron_dispatch(): void
catalog_refresh_background(); 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) // Plugins: wiederkehrende Aufgaben (höchstens alle 5 Minuten)
if (pseudo_cron_try_claim($pdo, 'plugins_cron_last_run', 300)) { if (pseudo_cron_try_claim($pdo, 'plugins_cron_last_run', 300)) {
plugin_hook('cron.run', ['time' => time()]); plugin_hook('cron.run', ['time' => time()]);
+1 -1
View File
@@ -124,7 +124,7 @@ function security_check_settings(PDO $pdo): array
// Reverse-Proxy ohne Konfiguration: alle Besucher teilen sich eine IP (gemeinsame Login-Sperre) // Reverse-Proxy ohne Konfiguration: alle Besucher teilen sich eine IP (gemeinsame Login-Sperre)
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && !request_from_trusted_proxy()) { 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 // Fehlerausgabe
+17 -1
View File
@@ -59,9 +59,25 @@ function update_find_assets(array $release, string $tag): array
return $out; 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. */ /** Kopfzeilen für Abrufe beim Update-Server; ein Zugangstoken ist nur nötig, wenn das Repository nicht öffentlich ist. */
function update_http_headers(): array function update_http_headers(): array
{ {
$token = (string) (config()['update']['token'] ?? ''); [$token] = update_token_info();
return $token !== '' ? ['Authorization: token ' . $token] : []; return $token !== '' ? ['Authorization: token ' . $token] : [];
} }
+149 -22
View File
@@ -9,12 +9,82 @@
* Sicherheitsgründen vom Server gelöscht oder zumindest gesperrt werden. * 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(); session_start();
define('ROOT_DIR', dirname(__DIR__)); define('ROOT_DIR', dirname(__DIR__));
define('LOCK_FILE', __DIR__ . '/.installed'); define('LOCK_FILE', __DIR__ . '/.installed');
require_once ROOT_DIR . '/database/sqlite_schema.php'; 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 function h(?string $s): string
{ {
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8'); return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
@@ -22,7 +92,7 @@ function h(?string $s): string
// URL der aktuellen Installation automatisch erkennen // URL der aktuellen Installation automatisch erkennen
$detectedUrl = (function(): string { $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'; $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
// install/ vom Pfad entfernen → Root-URL // install/ vom Pfad entfernen → Root-URL
$path = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\'); $path = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
@@ -55,22 +125,33 @@ $existingInstallation = (function (): bool {
$alreadyInstalled = file_exists(LOCK_FILE) || $existingInstallation; $alreadyInstalled = file_exists(LOCK_FILE) || $existingInstallation;
$errors = []; $errors = [];
$success = false; $success = false;
$manualConfig = null;
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Anforderungsprüfung // 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 = [ $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-Erweiterung', 'ok' => extension_loaded('pdo')],
['label' => 'PDO MySQL-Treiber (für Produktivbetrieb)', 'ok' => extension_loaded('pdo_mysql'), 'optional' => true], ['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' => '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' => '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' => '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 config/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/config')],
['label' => 'Verzeichnis uploads/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/uploads')], ['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; $requiredOk = true;
foreach ($checks as $c) { foreach ($checks as $c) {
if (empty($c['optional']) && !$c['ok']) { 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 ($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 ($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 (!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 ($adminPass !== $adminPass2) $errors[] = 'Die beiden Passwörter stimmen nicht überein.';
if (!extension_loaded('pdo_' . $driver)) $errors[] = "Die PHP-Erweiterung pdo_{$driver} ist nicht installiert."; 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']); $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $dbConfig['host'], $dbConfig['name']);
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass']); $pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass']);
} else { } 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']); @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 = new PDO('sqlite:' . $dbConfig['sqlite_path']);
$pdo->exec('PRAGMA foreign_keys = ON;'); $pdo->exec('PRAGMA foreign_keys = ON;');
} }
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (Throwable $e) { } 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 // Vordefiniertes Demo-Admin-Konto entfernen und durch das hier
// eingegebene, echte Admin-Konto ersetzen. // 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( $stmt = $pdo->prepare(
'INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)' '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" . " * WICHTIG: app_key niemals ändern – verschlüsselte Werte in der DB werden sonst unlesbar.\n"
. " */\n\nreturn " . var_export($configArray, true) . ";\n"; . " */\n\nreturn " . var_export($configArray, true) . ";\n";
if (file_put_contents(ROOT_DIR . '/config/config.php', $php) === false) { $cfgPath = ROOT_DIR . '/config/config.php';
$errors[] = 'config/config.php konnte nicht geschrieben werden. Bitte Schreibrechte des Verzeichnisses prüfen.'; 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 { } else {
file_put_contents(LOCK_FILE, date('Y-m-d H:i:s') . " von " . ($_SERVER['REMOTE_ADDR'] ?? 'CLI')); @chmod($cfgPath, 0640); // enthält Schlüssel und Datenbank-Passwort
$success = true; 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); box-shadow: 0 0 0 3px rgba(15,111,106,.12);
} }
.i-field input::placeholder { color: #b0bac6; } .i-field input::placeholder { color: #b0bac6; }
.i-field .i-hint { .i-hint, .i-field .i-hint {
font-size: .74rem; font-size: .74rem;
color: var(--muted); color: var(--muted);
margin-top: 4px; margin-top: 4px;
@@ -715,9 +825,11 @@ code {
<div class="i-card" style="text-align:center;padding:40px 28px"> <div class="i-card" style="text-align:center;padding:40px 28px">
<div class="i-success-icon">🔒</div> <div class="i-success-icon">🔒</div>
<h2 style="margin-bottom:10px">Bereits installiert</h2> <h2 style="margin-bottom:10px">Bereits installiert</h2>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px"> <?php if (is_file(LOCK_FILE)): ?>
Eingerichtet am <strong><?= h(is_file(LOCK_FILE) ? (string) file_get_contents(LOCK_FILE) : "bereits vorhandener Konfiguration") ?></strong> <p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">Eingerichtet am <strong><?= h((string) file_get_contents(LOCK_FILE)) ?></strong></p>
</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"> <div class="i-alert warning" style="margin-top:20px;text-align:left">
<span class="i-alert-icon">⚠️</span> <span class="i-alert-icon">⚠️</span>
<span>Bitte das Verzeichnis <code>install/</code> vom Server löschen, um diese Seite zu deaktivieren.</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="/login.php" class="i-btn-primary">Kundenportal</a>
<a href="/staff/login.php" class="i-btn-secondary">Mitarbeiter-Login</a> <a href="/staff/login.php" class="i-btn-secondary">Mitarbeiter-Login</a>
</div> </div>
<p style="font-size:.75rem;color:var(--muted);margin-top:16px"> <p style="font-size:.75rem;color:var(--muted);margin-top:16px;line-height:1.6">
Zum Wiederholen: <code>install/.installed</code> löschen 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> </p>
</div> </div>
@@ -779,6 +891,13 @@ code {
<?php foreach ($errors as $err): ?> <?php foreach ($errors as $err): ?>
<div class="i-alert error"><span class="i-alert-icon">✗</span><span><?= h($err) ?></span></div> <div class="i-alert error"><span class="i-alert-icon">✗</span><span><?= h($err) ?></span></div>
<?php endforeach; ?> <?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 --> <!-- Step 2: Configuration form -->
<div class="i-card"> <div class="i-card">
@@ -825,7 +944,7 @@ code {
<input type="password" id="db_pass" name="db_pass" placeholder="••••••••"> <input type="password" id="db_pass" name="db_pass" placeholder="••••••••">
</div> </div>
</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> </div>
<!-- Website --> <!-- Website -->
@@ -849,7 +968,7 @@ code {
</div> </div>
<div class="i-field"> <div class="i-field">
<label for="admin_email">E-Mail</label> <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> </div>
<div class="i-row"> <div class="i-row">
@@ -857,6 +976,7 @@ code {
<label for="admin_password">Passwort</label> <label for="admin_password">Passwort</label>
<input type="password" id="admin_password" name="admin_password" placeholder="••••••••" required> <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-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>
<div class="i-field"> <div class="i-field">
<label for="admin_password2">Passwort wiederholen</label> <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 // Password strength bar
var pwInput = document.getElementById('admin_password'); var pwInput = document.getElementById('admin_password');
var pwFill = document.getElementById('pw-fill'); var pwFill = document.getElementById('pw-fill');
if (pwInput && pwFill) { if (pwInput && pwFill) {
pwInput.addEventListener('input', function() { pwInput.addEventListener('input', function() {
var v = this.value, score = 0; var v = this.value, score = 0;
if (v.length >= 8) score++; if (v.length >= 10) score++;
if (v.length >= 12) score++; if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++;
if (/[A-Z]/.test(v)) score++;
if (/[0-9]/.test(v)) score++; if (/[0-9]/.test(v)) score++;
if (/[^A-Za-z0-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 colors = ['#ef4444','#f97316','#eab308','#22c55e','#16a34a'];
var widths = ['20%','40%','60%','80%','100%']; var widths = ['20%','40%','60%','80%','100%'];
pwFill.style.width = score > 0 ? widths[score-1] : '0%'; pwFill.style.width = score > 0 ? widths[score-1] : '0%';
+103 -35
View File
@@ -59,7 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
usort($files, fn($a,$b) => filemtime($b)-filemtime($a)); usort($files, fn($a,$b) => filemtime($b)-filemtime($a));
foreach (array_slice($files, $keep) as $old) @unlink($old); 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"); 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}" 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']))); : "Backup erstellt, aber NICHT wiederherstellbar: " . implode(' ', $verified['errors'])));
@@ -164,7 +164,7 @@ $backupDir = realpath(__DIR__ . '/../backups') ?: (dirname(__DIR__) . '/backup
$backupFiles = []; $backupFiles = [];
if (is_dir($backupDir)) { if (is_dir($backupDir)) {
foreach (array_merge(glob($backupDir.'/backup_*.zip')?:[], glob($backupDir.'/backup_*.tar.gz')?:[]) as $f) { 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']); 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'); $lastAutoRun = get_setting('cron_process_backups_last_run');
$lastAutoBkp = bget('last_auto_backup'); $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'; $pageTitle = 'Backup';
$activePage = 'backup'; $activePage = 'backup';
require __DIR__ . '/_header.php'; require __DIR__ . '/_header.php';
?> ?>
<?= plugin_subpage_tabs('backup', $staff, 'parent') ?>
<div class="topbar"> <div class="topbar">
<h1>💾 Backup</h1> <h1>💾 Backup</h1>
<form method="post" id="backup-form"> <form method="post" id="backup-form">
@@ -188,37 +212,80 @@ require __DIR__ . '/_header.php';
<!-- Statistiken --> <!-- Statistiken -->
<div class="stat-grid" style="margin-bottom:20px"> <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"><?= count($rows) ?></div><div class="lbl">Backups vorhanden</div>
<div class="stat-card"><div class="num"><?= $totalSize ?> MB</div><div class="lbl">Gesamtgröße</div></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"><?= $backupFiles ? date('d.m.Y', $backupFiles[0]['date']) : '–' ?></div><div class="lbl">Letztes Backup</div></div> <div class="stat-card"><div class="num"><?= e($fmtMb($sizeAll)) ?></div><div class="lbl">Gesamtgröße</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="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> </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 --> <!-- Backup-Liste -->
<div class="card" style="margin-bottom:20px"> <div class="card" style="margin-bottom:20px">
<h3>Vorhandene Backups</h3> <div style="display:flex; justify-content:space-between; align-items:baseline; gap:12px; flex-wrap:wrap">
<?php $lastCheck = json_decode((string) get_setting('backup_last_check', ''), true); if (is_array($lastCheck)): ?> <h3 style="margin:0">Vorhandene Backups</h3>
<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']) ?>: <?php $lastCheck = json_decode((string) get_setting('backup_last_check', ''), true); if (is_array($lastCheck)): ?>
<strong style="color:<?= $lastCheck['ok'] ? '#15803d' : '#dc2626' ?>"><?= $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?></strong></p> <span class="muted" style="font-size:.8rem">Letzte Prüfung: <?= e(date('d.m.Y H:i', strtotime($lastCheck['at']))) ?> –
<?php endif; ?> <strong style="color:<?= $lastCheck['ok'] ? '#15803d' : '#dc2626' ?>"><?= $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?></strong></span>
<?php if (!$backupFiles): ?> <?php endif; ?>
</div>
<?php if (!$rows): ?>
<div class="empty-state">Noch keine Backups vorhanden.</div> <div class="empty-state">Noch keine Backups vorhanden.</div>
<?php else: ?> <?php else: ?>
<table> <div class="bk-list" style="margin-top:8px">
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th><th style="width:160px"></th></tr></thead> <?php foreach ($rows as $b):
<tbody> $isLocal = ($b['source'] ?? 'local') === 'local';
<?php foreach ($backupFiles as $b): ?> $chk = $isLocal ? ($checkMap[$b['name']] ?? null) : null;
<tr> ?>
<td style="font-family:monospace; font-size:.82rem"><?= e($b['name']) ?></td> <div class="bk-row">
<td class="muted"><?= $b['size'] ?> MB</td> <div class="bk-name">
<td class="muted"><?= date('d.m.Y H:i', $b['date']) ?></td> <code><?= e($b['name']) ?></code>
<td style="display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start"> <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>
<a href="/staff/backup_download.php?file=<?= urlencode($b['name']) ?>" class="btn secondary" style="padding:5px 10px; font-size:.78rem">⬇️ Download</a> </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')): ?> <?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']) ?>"> <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" style="padding:5px 10px; font-size:.78rem" title="Prüft, ob sich das Backup wiederherstellen lässt (verändert nichts)">✔️ Prüfen</button></form> <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>
<details style="position:relative"><summary class="btn secondary" style="padding:5px 10px; font-size:.78rem; list-style:none; cursor:pointer">♻️ Wiederherstellen</summary> <?php endif; ?>
<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)"> <?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']) ?>"> <?= 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> <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> <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> <input type="text" name="confirm" autocomplete="off" required>
<button type="submit" class="btn" style="margin-top:8px; background:var(--danger)">Jetzt wiederherstellen</button> <button type="submit" class="btn" style="margin-top:8px; background:var(--danger)">Jetzt wiederherstellen</button>
</form></details> </form></details>
<?php endif; ?> <?php endif; ?>
<form method="post" onsubmit="return confirm('Backup löschen?')" style="margin:0"> <?php if ($isLocal): ?>
<form method="post" onsubmit="return confirm('Dieses Backup auf dem Server löschen?')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="action" value="delete_backup"> <input type="hidden" name="action" value="delete_backup">
<input type="hidden" name="filename" value="<?= e($b['name']) ?>"> <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> </form>
</td> <?php endif; ?>
</tr> </div>
<?php endforeach; ?> </div>
</tbody> <?php endforeach; ?>
</table> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:20px"> <div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:20px">
<!-- Zeitplan --> <!-- Zeitplan -->
@@ -251,6 +318,7 @@ require __DIR__ . '/_header.php';
Cron-Job einrichten: <code>php <?= e(dirname(__DIR__)) ?>/cron/process_backups.php</code><br> 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; ?> <?php if ($lastAutoRun): ?><span style="font-size:.78rem">Letzter Lauf: <?= format_dt($lastAutoRun) ?></span><?php endif; ?>
</p> </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"> <form method="post">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="action" value="save_schedule"> <input type="hidden" name="action" value="save_schedule">