diff --git a/DeskHub-Webseite/includes/backup.php b/DeskHub-Webseite/includes/backup.php
index 3199e9b7..e9f1ad04 100644
--- a/DeskHub-Webseite/includes/backup.php
+++ b/DeskHub-Webseite/includes/backup.php
@@ -22,9 +22,18 @@ function backup_excluded_fragments(): array
* @param string|null $rootDir Ordner, dessen Dateien gesichert werden (Standard: DeskHub-Hauptordner)
* @param string|null $backupDir Zielordner (Standard: backups/ im Hauptordner)
* @param PDO|null $pdo Datenbankverbindung (Standard: get_db())
+ * @param array $options Optional: 'with_database' (bool, Standard true), 'with_files' (bool, Standard true),
+ * 'only' (Liste von Pfaden relativ zum Hauptordner, nur diese Dateien/Ordner sichern),
+ * 'exclude' (Liste von Pfaden relativ zum Hauptordner, die nicht gesichert werden),
+ * 'verify' (bool, Standard true: Backup sofort automatisch auf Wiederherstellbarkeit prüfen und das Ergebnis merken)
*/
-function create_backup(?string $rootDir = null, ?string $backupDir = null, ?PDO $pdo = null): array
+function create_backup(?string $rootDir = null, ?string $backupDir = null, ?PDO $pdo = null, array $options = []): array
{
+ $withDb = $options['with_database'] ?? true;
+ $withFiles = $options['with_files'] ?? true;
+ $norm = fn(string $p) => trim(str_replace(chr(92), '/', $p), '/');
+ $onlyRel = array_values(array_filter(array_map($norm, (array) ($options['only'] ?? []))));
+ $excludeRel = array_values(array_filter(array_map($norm, (array) ($options['exclude'] ?? []))));
$rootDir = realpath($rootDir ?? __DIR__ . '/..');
$backupDir = $backupDir ?? $rootDir . '/backups';
$pdo = $pdo ?? get_db();
@@ -93,7 +102,7 @@ Deny from all
);
$files = [];
- foreach ($iter as $file) {
+ foreach ($withFiles ? $iter : [] as $file) {
if (!$file->isFile()) continue;
$filePath = $file->getRealPath();
$norm = str_replace('\\', '/', $filePath);
@@ -106,7 +115,18 @@ Deny from all
}
if ($liveDb && ($filePath === $liveDb || $filePath === $liveDb . '-wal' || $filePath === $liveDb . '-shm' || $filePath === $liveDb . '-journal')) $skip = true;
if ($skip) continue;
- $files[$filePath] = str_replace(chr(92), '/', substr($filePath, strlen($rootDir) + 1)); // im ZIP immer mit /
+ $rel = str_replace(chr(92), '/', substr($filePath, strlen($rootDir) + 1)); // im ZIP immer mit /
+ // Auswahl und Ausschlüsse (relativ zum Hauptordner, ganze Ordner oder einzelne Dateien)
+ $under = fn(array $list) => (bool) array_filter($list, fn($p) => $rel === $p || str_starts_with($rel, $p . '/'));
+ if ($onlyRel && !$under($onlyRel)) continue;
+ if ($excludeRel && $under($excludeRel)) continue;
+ $files[$filePath] = $rel;
+ }
+
+ // Dateien, die der Webserver-Benutzer nicht lesen kann (fremde Besitzer/Rechte), würden das ganze Backup scheitern lassen: überspringen und melden
+ $skipped = [];
+ foreach ($files as $filePath => $relativePath) {
+ if (!is_readable($filePath)) { $skipped[] = $relativePath; unset($files[$filePath]); }
}
// Abzug erzeugen
@@ -116,7 +136,7 @@ Deny from all
$tableCount = 0;
$dbError = null;
- if ($driver === 'sqlite') {
+ if ($withDb && $driver === 'sqlite') {
$snapshot = tempnam(sys_get_temp_dir(), 'dhbk');
@unlink($snapshot);
try {
@@ -130,7 +150,7 @@ Deny from all
if ($sqliteSnapshot) {
$tableCount = (int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchColumn();
}
- } elseif ($driver === 'mysql') {
+ } elseif ($withDb && $driver === 'mysql') {
try {
$dbName = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
$dump = "-- DeskHub MySQL Backup\n-- Erstellt: " . date('Y-m-d H:i:s') . "\n-- Datenbank: " . $dbName . "\n\nSET FOREIGN_KEY_CHECKS=0;\n\n";
@@ -175,7 +195,7 @@ Deny from all
$manifest = json_encode([
'created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null,
- 'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files),
+ 'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files), 'skipped' => array_slice($skipped, 0, 50),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
// ZIP erstellen – ZipArchive wenn verfügbar, sonst eigene Implementierung
@@ -191,7 +211,32 @@ Deny from all
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
$zip->addFromString('backup_manifest.json', $manifest);
- $zip->close();
+ $closed = @$zip->close();
+ if (!$closed || !file_exists($backupPath)) {
+ $zipReason = trim((string) $zip->getStatusString());
+ // Zweiter Versuch: nur Dateien, die sich wirklich öffnen lassen
+ @unlink($backupPath);
+ $retry = new ZipArchive();
+ if ($retry->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
+ foreach ($files as $filePath => $relativePath) {
+ $h = @fopen($filePath, 'rb');
+ if ($h === false) { $skipped[] = $relativePath; continue; }
+ fclose($h);
+ $retry->addFile($filePath, $relativePath);
+ }
+ if ($sqlDump && $sqlFile) $retry->addFromString($sqlFile, $sqlDump);
+ if ($sqliteSnapshot) $retry->addFile($sqliteSnapshot, 'database_backup.sqlite');
+ $retry->addFromString('backup_manifest.json', json_encode(['created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null, 'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files) - count($skipped), 'skipped' => array_slice($skipped, 0, 50)], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+ @$retry->close();
+ if (!file_exists($backupPath)) $zipReason = trim($zipReason . ' ' . $retry->getStatusString());
+ }
+ if (!file_exists($backupPath)) {
+ if ($sqliteSnapshot) @unlink($sqliteSnapshot);
+ $free = @disk_free_space($backupDir);
+ return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt' . ($zipReason !== '' ? ' (' . $zipReason . ')' : '')
+ . '. Prüfen: Ist der Ordner „' . basename($backupDir) . '“ beschreibbar' . ($free !== false ? ' und ist genug Speicherplatz frei (aktuell ' . round($free / 1048576) . ' MB)' : '') . '?'];
+ }
+ }
} else {
// Eigene ZIP-Implementierung (kein php_zip nötig)
$zip = new SimpleZip($backupPath);
@@ -213,11 +258,21 @@ Deny from all
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt.'];
}
+ // Automatische Prüfung: Ein Backup, das sich nicht wiederherstellen lässt, ist wertlos. Das Ergebnis steht in der Liste und im Hinweis im System.
+ // (Backups ohne Datenbank, zum Beispiel reine Datei-Sicherungen von Plugins, lassen sich so nicht prüfen und werden hier ausgelassen.)
+ $verify = null;
+ if ($withDb && ($options['verify'] ?? true)) {
+ if (!function_exists('backup_verify_and_record')) require_once __DIR__ . '/backup_restore.php';
+ $verify = backup_verify_and_record($backupPath);
+ }
+
return [
'ok' => true,
'filename' => $backupName,
'path' => $backupPath,
'files' => count($files),
+ 'skipped' => $skipped,
+ 'verify' => $verify,
'size' => round(filesize($backupPath) / 1024 / 1024, 2),
];
}
diff --git a/DeskHub-Webseite/includes/backup_restore.php b/DeskHub-Webseite/includes/backup_restore.php
index d2cc782a..908921e5 100644
--- a/DeskHub-Webseite/includes/backup_restore.php
+++ b/DeskHub-Webseite/includes/backup_restore.php
@@ -1,298 +1,343 @@
- 'Hochgeladene Dateien (Anhänge)', 'plugins' => 'Plugins', 'app_downloads' => 'App-Dateien'];
-}
-
-/** Ist der Eintragsname im ZIP unbedenklich (keine .., keine absoluten Pfade)? */
-function backup_entry_name_ok(string $name): bool
-{
- $n = str_replace('\\', '/', $name);
- return $n !== '' && $n[0] !== '/' && !str_contains($n, "\0") && !preg_match('#(^|/)\.\.(/|$)#', $n) && !preg_match('#^[A-Za-z]:#', $n);
-}
-
-/** Zerlegt einen SQL-Dump in Anweisungen (Zeilenweise, mit Beachtung von Zeichenketten). */
-function backup_sql_statements(string $file): Generator
-{
- $fh = fopen($file, 'rb');
- $buf = '';
- $inQuote = false;
- while (($line = fgets($fh)) !== false) {
- if (!$inQuote && $buf === '' && (trim($line) === '' || str_starts_with($line, '--'))) continue;
- $buf .= $line;
- // Anführungszeichen-Zustand über die Zeile fortschreiben
- $n = strlen($line);
- for ($i = 0; $i < $n; $i++) {
- $c = $line[$i];
- if ($inQuote) {
- if ($c === '\\') { $i++; continue; }
- if ($c === "'") $inQuote = false;
- } elseif ($c === "'") {
- $inQuote = true;
- }
- }
- if (!$inQuote && substr(rtrim($line), -1) === ';') {
- yield trim($buf);
- $buf = '';
- }
- }
- if (trim($buf) !== '') yield trim($buf);
- fclose($fh);
-}
-
-/** Entpackt einen Eintrag in eine temporäre Datei (Streaming, auch für große Dateien). */
-function backup_extract_entry(ZipArchive $zip, string $name): ?string
-{
- $in = $zip->getStream($name);
- if (!$in) return null;
- $tmp = tempnam(sys_get_temp_dir(), 'dhrs');
- $out = fopen($tmp, 'wb');
- stream_copy_to_stream($in, $out);
- fclose($in);
- fclose($out);
- return $tmp;
-}
-
-/**
- * Prüft ein Backup, ohne etwas zu verändern.
- *
- * @return array{ok:bool, errors:string[], warnings:string[], info:array}
- */
-function backup_verify(string $zipPath): array
-{
- $r = ['ok' => false, 'errors' => [], 'warnings' => [], 'info' => []];
- if (!class_exists('ZipArchive')) { $r['errors'][] = 'Die PHP-Erweiterung zip fehlt.'; return $r; }
- if (!is_file($zipPath) || filesize($zipPath) < 100) { $r['errors'][] = 'Die Backup-Datei fehlt oder ist leer.'; return $r; }
- $zip = new ZipArchive();
- if ($zip->open($zipPath, ZipArchive::CHECKCONS) !== true) { $r['errors'][] = 'Die ZIP-Datei ist beschädigt oder unvollständig.'; return $r; }
-
- $names = [];
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $n = (string) $zip->getNameIndex($i);
- if (!backup_entry_name_ok($n)) { $zip->close(); $r['errors'][] = "Unzulässiger Pfad im Backup: {$n}"; return $r; }
- $names[$n] = true;
- }
- $r['info']['files'] = count($names);
-
- $manifest = null;
- if (isset($names['backup_manifest.json'])) {
- $manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true);
- if (!is_array($manifest)) { $r['errors'][] = 'Das Manifest im Backup ist unlesbar.'; $manifest = null; }
- }
- $r['info']['created'] = $manifest['created'] ?? null;
- $r['info']['version'] = $manifest['version'] ?? null;
-
- $key = ['tickets', 'users', 'staff', 'settings'];
- if (isset($names['database_backup.sqlite'])) {
- $r['info']['driver'] = 'sqlite';
- $tmp = backup_extract_entry($zip, 'database_backup.sqlite');
- if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') {
- $r['errors'][] = 'Der Datenbank-Abzug im Backup ist keine gültige SQLite-Datenbank.';
- } else {
- try {
- $db = new PDO('sqlite:' . $tmp);
- $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
- $integrity = $db->query('PRAGMA integrity_check')->fetchColumn();
- if ($integrity !== 'ok') $r['errors'][] = 'Die Datenbank im Backup ist beschädigt: ' . $integrity;
- $tables = $db->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
- $r['info']['tables'] = count($tables);
- foreach ($key as $t) if (!in_array($t, $tables, true)) $r['errors'][] = "In der Datenbank des Backups fehlt die Tabelle \"{$t}\".";
- foreach (['tickets', 'users', 'staff'] as $t) if (in_array($t, $tables, true)) $r['info'][$t] = (int) $db->query("SELECT COUNT(*) FROM {$t}")->fetchColumn();
- } catch (Throwable $e) {
- $r['errors'][] = 'Die Datenbank im Backup lässt sich nicht lesen: ' . $e->getMessage();
- }
- $db = null;
- }
- if ($tmp) @unlink($tmp);
- } elseif (isset($names['database_backup.sql'])) {
- $r['info']['driver'] = 'mysql';
- $tmp = backup_extract_entry($zip, 'database_backup.sql');
- if (!$tmp) {
- $r['errors'][] = 'Der Datenbank-Abzug im Backup ist nicht lesbar.';
- } else {
- $creates = [];
- $count = 0;
- $last = '';
- foreach (backup_sql_statements($tmp) as $stmt) {
- $count++;
- $last = $stmt;
- if (preg_match('/^CREATE TABLE `?([A-Za-z0-9_]+)`?/i', $stmt, $m)) $creates[$m[1]] = true;
- }
- $r['info']['tables'] = count($creates);
- $r['info']['statements'] = $count;
- foreach ($key as $t) if (!isset($creates[$t])) $r['errors'][] = "Im Datenbank-Abzug fehlt die Tabelle \"{$t}\".";
- if (stripos($last, 'FOREIGN_KEY_CHECKS=1') === false) $r['errors'][] = 'Der Datenbank-Abzug ist unvollständig (das Ende fehlt).';
- @unlink($tmp);
- }
- } elseif (isset($names['database/deskhub.sqlite'])) {
- // Ältere Backups enthalten die laufende SQLite-Datei als Kopie
- $r['info']['driver'] = 'sqlite';
- $r['warnings'][] = 'Dieses ältere Backup enthält eine Kopie der laufenden Datenbankdatei. Sie lässt sich nicht garantiert fehlerfrei wiederherstellen.';
- $tmp = backup_extract_entry($zip, 'database/deskhub.sqlite');
- if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') $r['errors'][] = 'Die Datenbankkopie im Backup ist keine gültige SQLite-Datenbank.';
- else $r['errors'][] = 'Backups im alten Format können nicht automatisch wiederhergestellt werden. Bitte ein neues Backup erstellen.';
- if ($tmp) @unlink($tmp);
- } else {
- $r['errors'][] = 'Im Backup fehlt der Datenbank-Abzug.';
- }
- $zip->close();
- $r['ok'] = !$r['errors'];
- return $r;
-}
-
-/**
- * Stellt ein Backup wieder her.
- *
- * Optionen: database (Standard true), dirs (Liste aus backup_restorable_dirs(), Standard alle),
- * root (Zielordner für Dateien), backup_dir (Zielordner des Sicherheits-Backups), safety_backup (Standard true).
- *
- * @return array{ok:bool, error?:string, tables?:int, rows?:int, files?:int, safety?:string, notes?:string[]}
- */
-function backup_restore(PDO $pdo, string $zipPath, array $options = []): array
-{
- @set_time_limit(600);
- $check = backup_verify($zipPath);
- if (!$check['ok']) return ['ok' => false, 'error' => 'Das Backup ist nicht wiederherstellbar: ' . implode(' ', $check['errors'])];
- $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
- if (($check['info']['driver'] ?? '') !== $driver) {
- return ['ok' => false, 'error' => 'Das Backup stammt aus einer ' . strtoupper((string) $check['info']['driver']) . '-Datenbank, dieses System nutzt ' . strtoupper($driver) . '. Eine Wiederherstellung ist nur auf demselben Datenbanktyp möglich.'];
- }
- $root = realpath($options['root'] ?? __DIR__ . '/..');
- $result = ['ok' => true, 'tables' => 0, 'rows' => 0, 'files' => 0, 'notes' => []];
-
- if (($options['safety_backup'] ?? true)) {
- $safety = create_backup($root, $options['backup_dir'] ?? null, $pdo);
- if (!$safety['ok']) return ['ok' => false, 'error' => 'Das Sicherheits-Backup vor der Wiederherstellung ist fehlgeschlagen, es wurde nichts verändert: ' . $safety['error']];
- $result['safety'] = $safety['filename'];
- }
- $zip = new ZipArchive();
- $zip->open($zipPath);
-
- try {
- if ($options['database'] ?? true) {
- $db = $driver === 'sqlite' ? backup_restore_sqlite($pdo, $zip) : backup_restore_mysql($pdo, $zip);
- $result['tables'] = $db['tables'];
- $result['rows'] = $db['rows'];
- $result['notes'] = array_merge($result['notes'], $db['notes']);
- }
- $dirs = array_values(array_intersect(array_keys(backup_restorable_dirs()), (array) ($options['dirs'] ?? array_keys(backup_restorable_dirs()))));
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
- if (substr($name, -1) === '/' || !backup_entry_name_ok($name)) continue;
- $top = explode('/', $name, 2)[0];
- if (!in_array($top, $dirs, true)) continue;
- $dest = $root . '/' . $name;
- if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0755, true);
- $in = $zip->getStream($zip->getNameIndex($i));
- if (!$in) continue;
- $out = fopen($dest, 'wb');
- if (!$out) { fclose($in); $result['notes'][] = "Datei nicht beschreibbar: {$name}"; continue; }
- stream_copy_to_stream($in, $out);
- fclose($in);
- fclose($out);
- $result['files']++;
- }
- } catch (Throwable $e) {
- $zip->close();
- return ['ok' => false, 'error' => 'Wiederherstellung abgebrochen: ' . $e->getMessage() . (isset($result['safety']) ? ' Der Stand von vorher steht im Sicherheits-Backup ' . $result['safety'] . '.' : '')];
- }
- $zip->close();
- return $result;
-}
-
-/** SQLite: Inhalte aller Tabellen des Backups in die laufende Datenbank übernehmen (Spalten, die es in beiden gibt). */
-function backup_restore_sqlite(PDO $pdo, ZipArchive $zip): array
-{
- $tmp = backup_extract_entry($zip, 'database_backup.sqlite');
- if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
- $out = ['tables' => 0, 'rows' => 0, 'notes' => []];
- $pdo->exec('PRAGMA foreign_keys = OFF'); // außerhalb einer Transaktion
- try {
- $pdo->exec('ATTACH DATABASE ' . $pdo->quote($tmp) . ' AS bk');
- $pdo->beginTransaction();
- $bkTables = $pdo->query("SELECT name FROM bk.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
- $liveTables = $pdo->query("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
- $cols = fn(string $schema, string $t) => array_column($pdo->query("PRAGMA {$schema}.table_info(\"{$t}\")")->fetchAll(PDO::FETCH_ASSOC), 'name');
- $missing = [];
- foreach ($liveTables as $t) {
- if (!in_array($t, $bkTables, true)) { $missing[] = $t; continue; }
- $common = array_values(array_intersect($cols('main', $t), $cols('bk', $t)));
- $pdo->exec("DELETE FROM main.\"{$t}\"");
- if ($common) {
- $list = '"' . implode('","', $common) . '"';
- $pdo->exec("INSERT INTO main.\"{$t}\" ({$list}) SELECT {$list} FROM bk.\"{$t}\"");
- }
- $out['tables']++;
- $out['rows'] += (int) $pdo->query("SELECT COUNT(*) FROM main.\"{$t}\"")->fetchColumn();
- }
- // Zähler der automatischen Nummern übernehmen, damit neue Datensätze nicht alte Nummern wiederverwenden
- $hasSeq = (bool) $pdo->query("SELECT 1 FROM bk.sqlite_master WHERE name = 'sqlite_sequence'")->fetchColumn();
- if ($hasSeq) {
- $pdo->exec('DELETE FROM main.sqlite_sequence');
- $pdo->exec('INSERT INTO main.sqlite_sequence (name, seq) SELECT name, seq FROM bk.sqlite_sequence');
- }
- $pdo->commit();
- if ($missing) $out['notes'][] = 'Tabellen, die es im Backup noch nicht gab, blieben unverändert: ' . implode(', ', $missing) . '. Danach unter System-Update „Datenbank aktualisieren“ ausführen.';
- } catch (Throwable $e) {
- if ($pdo->inTransaction()) $pdo->rollBack();
- throw $e;
- } finally {
- try { $pdo->exec('DETACH DATABASE bk'); } catch (Throwable $e) {}
- $pdo->exec('PRAGMA foreign_keys = ON');
- @unlink($tmp);
- }
- return $out;
-}
-
-/** MySQL: den SQL-Abzug Anweisung für Anweisung einspielen. */
-function backup_restore_mysql(PDO $pdo, ZipArchive $zip): array
-{
- $tmp = backup_extract_entry($zip, 'database_backup.sql');
- if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
- $out = ['tables' => 0, 'rows' => 0, 'notes' => []];
- try {
- foreach (backup_sql_statements($tmp) as $stmt) {
- $pdo->exec($stmt);
- if (preg_match('/^CREATE TABLE/i', $stmt)) $out['tables']++;
- if (preg_match('/^INSERT INTO/i', $stmt)) $out['rows'] += max(1, substr_count($stmt, "),\n(") + 1);
- }
- } finally {
- @unlink($tmp);
- }
- $out['notes'][] = 'Danach unter System-Update „Datenbank aktualisieren“ ausführen, falls das Backup von einer älteren Version stammt.';
- return $out;
-}
-
-/** Pfad eines Backups im backups-Ordner anhand des Dateinamens (nur echte Backups, sonst null). */
-function backup_path_for(string $filename, ?string $backupDir = null): ?string
-{
- $dir = realpath($backupDir ?? __DIR__ . '/../backups');
- $file = basename($filename);
- if (!$dir || !preg_match('/^backup_[\d\-_a-f]+\.zip$/', $file) || !is_file($dir . '/' . $file)) return null;
- return $dir . '/' . $file;
-}
-
-/**
- * Prüft ein Backup und merkt sich das Ergebnis (für die Anzeige und den Hinweis im System). Ein Backup, das sich nicht
- * wiederherstellen lässt, ist wertlos: Das soll auffallen, bevor man es braucht.
- */
-function backup_verify_and_record(string $zipPath): array
-{
- $r = backup_verify($zipPath);
- set_setting('backup_last_check', json_encode(['file' => basename($zipPath), 'at' => date('Y-m-d H:i:s'), 'ok' => $r['ok'], 'errors' => $r['errors'], 'info' => $r['info']], JSON_UNESCAPED_UNICODE));
- return $r;
-}
+ 'Hochgeladene Dateien (Anhänge)', 'plugins' => 'Plugins', 'app_downloads' => 'App-Dateien'];
+}
+
+/** Ist der Eintragsname im ZIP unbedenklich (keine .., keine absoluten Pfade)? */
+function backup_entry_name_ok(string $name): bool
+{
+ $n = str_replace('\\', '/', $name);
+ return $n !== '' && $n[0] !== '/' && !str_contains($n, "\0") && !preg_match('#(^|/)\.\.(/|$)#', $n) && !preg_match('#^[A-Za-z]:#', $n);
+}
+
+/** Zerlegt einen SQL-Dump in Anweisungen (Zeilenweise, mit Beachtung von Zeichenketten). */
+function backup_sql_statements(string $file): Generator
+{
+ $fh = fopen($file, 'rb');
+ $buf = '';
+ $inQuote = false;
+ while (($line = fgets($fh)) !== false) {
+ if (!$inQuote && $buf === '' && (trim($line) === '' || str_starts_with($line, '--'))) continue;
+ $buf .= $line;
+ // Anführungszeichen-Zustand über die Zeile fortschreiben
+ $n = strlen($line);
+ for ($i = 0; $i < $n; $i++) {
+ $c = $line[$i];
+ if ($inQuote) {
+ if ($c === '\\') { $i++; continue; }
+ if ($c === "'") $inQuote = false;
+ } elseif ($c === "'") {
+ $inQuote = true;
+ }
+ }
+ if (!$inQuote && substr(rtrim($line), -1) === ';') {
+ yield trim($buf);
+ $buf = '';
+ }
+ }
+ if (trim($buf) !== '') yield trim($buf);
+ fclose($fh);
+}
+
+/** Entpackt einen Eintrag in eine temporäre Datei (Streaming, auch für große Dateien). */
+function backup_extract_entry(ZipArchive $zip, string $name): ?string
+{
+ $in = $zip->getStream($name);
+ if (!$in) return null;
+ $tmp = tempnam(sys_get_temp_dir(), 'dhrs');
+ $out = fopen($tmp, 'wb');
+ stream_copy_to_stream($in, $out);
+ fclose($in);
+ fclose($out);
+ return $tmp;
+}
+
+/**
+ * Prüft ein Backup, ohne etwas zu verändern.
+ *
+ * @return array{ok:bool, errors:string[], warnings:string[], info:array}
+ */
+function backup_verify(string $zipPath): array
+{
+ $r = ['ok' => false, 'errors' => [], 'warnings' => [], 'info' => []];
+ if (!class_exists('ZipArchive')) { $r['errors'][] = 'Die PHP-Erweiterung zip fehlt.'; return $r; }
+ if (!is_file($zipPath) || filesize($zipPath) < 100) { $r['errors'][] = 'Die Backup-Datei fehlt oder ist leer.'; return $r; }
+ $zip = new ZipArchive();
+ if ($zip->open($zipPath, ZipArchive::CHECKCONS) !== true) { $r['errors'][] = 'Die ZIP-Datei ist beschädigt oder unvollständig.'; return $r; }
+
+ $names = [];
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $n = (string) $zip->getNameIndex($i);
+ if (!backup_entry_name_ok($n)) { $zip->close(); $r['errors'][] = "Unzulässiger Pfad im Backup: {$n}"; return $r; }
+ $names[$n] = true;
+ }
+ $r['info']['files'] = count($names);
+
+ $manifest = null;
+ if (isset($names['backup_manifest.json'])) {
+ $manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true);
+ if (!is_array($manifest)) { $r['errors'][] = 'Das Manifest im Backup ist unlesbar.'; $manifest = null; }
+ }
+ $r['info']['created'] = $manifest['created'] ?? null;
+ $r['info']['version'] = $manifest['version'] ?? null;
+
+ $key = ['tickets', 'users', 'staff', 'settings'];
+ if (isset($names['database_backup.sqlite'])) {
+ $r['info']['driver'] = 'sqlite';
+ $tmp = backup_extract_entry($zip, 'database_backup.sqlite');
+ if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') {
+ $r['errors'][] = 'Der Datenbank-Abzug im Backup ist keine gültige SQLite-Datenbank.';
+ } else {
+ try {
+ $db = new PDO('sqlite:' . $tmp);
+ $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $integrity = $db->query('PRAGMA integrity_check')->fetchColumn();
+ if ($integrity !== 'ok') $r['errors'][] = 'Die Datenbank im Backup ist beschädigt: ' . $integrity;
+ $tables = $db->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
+ $r['info']['tables'] = count($tables);
+ foreach ($key as $t) if (!in_array($t, $tables, true)) $r['errors'][] = "In der Datenbank des Backups fehlt die Tabelle \"{$t}\".";
+ foreach (['tickets', 'users', 'staff'] as $t) if (in_array($t, $tables, true)) $r['info'][$t] = (int) $db->query("SELECT COUNT(*) FROM {$t}")->fetchColumn();
+ } catch (Throwable $e) {
+ $r['errors'][] = 'Die Datenbank im Backup lässt sich nicht lesen: ' . $e->getMessage();
+ }
+ $db = null;
+ }
+ if ($tmp) @unlink($tmp);
+ } elseif (isset($names['database_backup.sql'])) {
+ $r['info']['driver'] = 'mysql';
+ $tmp = backup_extract_entry($zip, 'database_backup.sql');
+ if (!$tmp) {
+ $r['errors'][] = 'Der Datenbank-Abzug im Backup ist nicht lesbar.';
+ } else {
+ $creates = [];
+ $count = 0;
+ $last = '';
+ foreach (backup_sql_statements($tmp) as $stmt) {
+ $count++;
+ $last = $stmt;
+ if (preg_match('/^CREATE TABLE `?([A-Za-z0-9_]+)`?/i', $stmt, $m)) $creates[$m[1]] = true;
+ }
+ $r['info']['tables'] = count($creates);
+ $r['info']['statements'] = $count;
+ foreach ($key as $t) if (!isset($creates[$t])) $r['errors'][] = "Im Datenbank-Abzug fehlt die Tabelle \"{$t}\".";
+ if (stripos($last, 'FOREIGN_KEY_CHECKS=1') === false) $r['errors'][] = 'Der Datenbank-Abzug ist unvollständig (das Ende fehlt).';
+ @unlink($tmp);
+ }
+ } elseif (isset($names['database/deskhub.sqlite'])) {
+ // Ältere Backups enthalten die laufende SQLite-Datei als Kopie
+ $r['info']['driver'] = 'sqlite';
+ $r['warnings'][] = 'Dieses ältere Backup enthält eine Kopie der laufenden Datenbankdatei. Sie lässt sich nicht garantiert fehlerfrei wiederherstellen.';
+ $tmp = backup_extract_entry($zip, 'database/deskhub.sqlite');
+ if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') $r['errors'][] = 'Die Datenbankkopie im Backup ist keine gültige SQLite-Datenbank.';
+ else $r['errors'][] = 'Backups im alten Format können nicht automatisch wiederhergestellt werden. Bitte ein neues Backup erstellen.';
+ if ($tmp) @unlink($tmp);
+ } else {
+ $r['errors'][] = 'Im Backup fehlt der Datenbank-Abzug.';
+ }
+ $zip->close();
+ $r['ok'] = !$r['errors'];
+ return $r;
+}
+
+/**
+ * Stellt ein Backup wieder her.
+ *
+ * Optionen: database (Standard true), dirs (Liste aus backup_restorable_dirs(), Standard alle),
+ * root (Zielordner für Dateien), backup_dir (Zielordner des Sicherheits-Backups), safety_backup (Standard true).
+ *
+ * @return array{ok:bool, error?:string, tables?:int, rows?:int, files?:int, safety?:string, notes?:string[]}
+ */
+function backup_restore(PDO $pdo, string $zipPath, array $options = []): array
+{
+ @set_time_limit(600);
+ $check = backup_verify($zipPath);
+ if (!$check['ok']) return ['ok' => false, 'error' => 'Das Backup ist nicht wiederherstellbar: ' . implode(' ', $check['errors'])];
+ $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ if (($check['info']['driver'] ?? '') !== $driver) {
+ return ['ok' => false, 'error' => 'Das Backup stammt aus einer ' . strtoupper((string) $check['info']['driver']) . '-Datenbank, dieses System nutzt ' . strtoupper($driver) . '. Eine Wiederherstellung ist nur auf demselben Datenbanktyp möglich.'];
+ }
+ $root = realpath($options['root'] ?? __DIR__ . '/..');
+ $result = ['ok' => true, 'tables' => 0, 'rows' => 0, 'files' => 0, 'notes' => []];
+
+ if (($options['safety_backup'] ?? true)) {
+ $safety = create_backup($root, $options['backup_dir'] ?? null, $pdo);
+ if (!$safety['ok']) return ['ok' => false, 'error' => 'Das Sicherheits-Backup vor der Wiederherstellung ist fehlgeschlagen, es wurde nichts verändert: ' . $safety['error']];
+ $result['safety'] = $safety['filename'];
+ }
+ $zip = new ZipArchive();
+ $zip->open($zipPath);
+
+ try {
+ if ($options['database'] ?? true) {
+ $db = $driver === 'sqlite' ? backup_restore_sqlite($pdo, $zip) : backup_restore_mysql($pdo, $zip);
+ $result['tables'] = $db['tables'];
+ $result['rows'] = $db['rows'];
+ $result['notes'] = array_merge($result['notes'], $db['notes']);
+ }
+ $dirs = array_values(array_intersect(array_keys(backup_restorable_dirs()), (array) ($options['dirs'] ?? array_keys(backup_restorable_dirs()))));
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
+ if (substr($name, -1) === '/' || !backup_entry_name_ok($name)) continue;
+ $top = explode('/', $name, 2)[0];
+ if (!in_array($top, $dirs, true)) continue;
+ $dest = $root . '/' . $name;
+ if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0755, true);
+ $in = $zip->getStream($zip->getNameIndex($i));
+ if (!$in) continue;
+ $out = fopen($dest, 'wb');
+ if (!$out) { fclose($in); $result['notes'][] = "Datei nicht beschreibbar: {$name}"; continue; }
+ stream_copy_to_stream($in, $out);
+ fclose($in);
+ fclose($out);
+ $result['files']++;
+ }
+ } catch (Throwable $e) {
+ $zip->close();
+ return ['ok' => false, 'error' => 'Wiederherstellung abgebrochen: ' . $e->getMessage() . (isset($result['safety']) ? ' Der Stand von vorher steht im Sicherheits-Backup ' . $result['safety'] . '.' : '')];
+ }
+ $zip->close();
+ return $result;
+}
+
+/** SQLite: Inhalte aller Tabellen des Backups in die laufende Datenbank übernehmen (Spalten, die es in beiden gibt). */
+function backup_restore_sqlite(PDO $pdo, ZipArchive $zip): array
+{
+ $tmp = backup_extract_entry($zip, 'database_backup.sqlite');
+ if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
+ $out = ['tables' => 0, 'rows' => 0, 'notes' => []];
+ $pdo->exec('PRAGMA foreign_keys = OFF'); // außerhalb einer Transaktion
+ try {
+ $pdo->exec('ATTACH DATABASE ' . $pdo->quote($tmp) . ' AS bk');
+ $pdo->beginTransaction();
+ $bkTables = $pdo->query("SELECT name FROM bk.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
+ $liveTables = $pdo->query("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
+ $cols = fn(string $schema, string $t) => array_column($pdo->query("PRAGMA {$schema}.table_info(\"{$t}\")")->fetchAll(PDO::FETCH_ASSOC), 'name');
+ $missing = [];
+ foreach ($liveTables as $t) {
+ if (!in_array($t, $bkTables, true)) { $missing[] = $t; continue; }
+ $common = array_values(array_intersect($cols('main', $t), $cols('bk', $t)));
+ $pdo->exec("DELETE FROM main.\"{$t}\"");
+ if ($common) {
+ $list = '"' . implode('","', $common) . '"';
+ $pdo->exec("INSERT INTO main.\"{$t}\" ({$list}) SELECT {$list} FROM bk.\"{$t}\"");
+ }
+ $out['tables']++;
+ $out['rows'] += (int) $pdo->query("SELECT COUNT(*) FROM main.\"{$t}\"")->fetchColumn();
+ }
+ // Zähler der automatischen Nummern übernehmen, damit neue Datensätze nicht alte Nummern wiederverwenden
+ $hasSeq = (bool) $pdo->query("SELECT 1 FROM bk.sqlite_master WHERE name = 'sqlite_sequence'")->fetchColumn();
+ if ($hasSeq) {
+ $pdo->exec('DELETE FROM main.sqlite_sequence');
+ $pdo->exec('INSERT INTO main.sqlite_sequence (name, seq) SELECT name, seq FROM bk.sqlite_sequence');
+ }
+ $pdo->commit();
+ if ($missing) $out['notes'][] = 'Tabellen, die es im Backup noch nicht gab, blieben unverändert: ' . implode(', ', $missing) . '. Danach unter System-Update „Datenbank aktualisieren“ ausführen.';
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ throw $e;
+ } finally {
+ try { $pdo->exec('DETACH DATABASE bk'); } catch (Throwable $e) {}
+ $pdo->exec('PRAGMA foreign_keys = ON');
+ @unlink($tmp);
+ }
+ return $out;
+}
+
+/** MySQL: den SQL-Abzug Anweisung für Anweisung einspielen. */
+function backup_restore_mysql(PDO $pdo, ZipArchive $zip): array
+{
+ $tmp = backup_extract_entry($zip, 'database_backup.sql');
+ if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
+ $out = ['tables' => 0, 'rows' => 0, 'notes' => []];
+ try {
+ foreach (backup_sql_statements($tmp) as $stmt) {
+ $pdo->exec($stmt);
+ if (preg_match('/^CREATE TABLE/i', $stmt)) $out['tables']++;
+ if (preg_match('/^INSERT INTO/i', $stmt)) $out['rows'] += max(1, substr_count($stmt, "),\n(") + 1);
+ }
+ } finally {
+ @unlink($tmp);
+ }
+ $out['notes'][] = 'Danach unter System-Update „Datenbank aktualisieren“ ausführen, falls das Backup von einer älteren Version stammt.';
+ return $out;
+}
+
+/** Pfad eines Backups im backups-Ordner anhand des Dateinamens (nur echte Backups, sonst null). */
+function backup_path_for(string $filename, ?string $backupDir = null): ?string
+{
+ $dir = realpath($backupDir ?? __DIR__ . '/../backups');
+ $file = basename($filename);
+ if (!$dir || !preg_match('/^backup_[\d\-_a-f]+\.zip$/', $file) || !is_file($dir . '/' . $file)) return null;
+ return $dir . '/' . $file;
+}
+
+/**
+ * Prüft ein Backup und merkt sich das Ergebnis (für die Anzeige und den Hinweis im System). Ein Backup, das sich nicht
+ * wiederherstellen lässt, ist wertlos: Das soll auffallen, bevor man es braucht.
+ */
+function backup_verify_and_record(string $zipPath): array
+{
+ $r = backup_verify($zipPath);
+ $now = date('Y-m-d H:i:s');
+ $wasNotified = !empty((json_decode((string) get_setting('backup_check_map', ''), true) ?: [])[basename($zipPath)]['notified']);
+ set_setting('backup_last_check', json_encode(['file' => basename($zipPath), 'at' => $now, 'ok' => $r['ok'], 'errors' => $r['errors'], 'info' => $r['info']], JSON_UNESCAPED_UNICODE));
+ // Ergebnis je Datei (für die Liste auf der Backup-Seite), nur die letzten 40
+ $map = json_decode((string) get_setting('backup_check_map', ''), true);
+ $map = is_array($map) ? $map : [];
+ $map[basename($zipPath)] = ['ok' => (bool) $r['ok'], 'at' => $now, 'notified' => $wasNotified];
+ // Defektes Backup: Administrator einmal per E-Mail warnen
+ if (!$r['ok'] && !$wasNotified && function_exists('send_mail')) {
+ $to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? ''));
+ if ($to !== '') {
+ $map[basename($zipPath)]['notified'] = true;
+ @send_mail($to, 'DeskHub: Backup nicht wiederherstellbar', '
Die automatische Prüfung hat ein Problem gefunden:
' . htmlspecialchars(basename($zipPath)) . '
' . htmlspecialchars(implode(' ', (array) $r['errors'])) . '
Erstelle ein neues Backup und prüfe die Einstellungen unter Adminportal → Backup.
');
+ }
+ }
+ if (count($map) > 40) $map = array_slice($map, -40, null, true);
+ set_setting('backup_check_map', json_encode($map));
+ return $r;
+}
+
+/**
+ * Prüft Backups automatisch nach: solche, die noch nie geprüft wurden, und solche, deren letzte Prüfung über $maxAgeDays Tage her ist
+ * (um spätere Beschädigungen zu bemerken). Pro Aufruf höchstens $limit Backups, neueste zuerst.
+ *
+ * @return array{checked:int,bad:int}
+ */
+function backup_verify_pending(int $limit = 2, int $maxAgeDays = 7, ?string $backupDir = null): array
+{
+ $dir = realpath($backupDir ?? __DIR__ . '/../backups');
+ $out = ['checked' => 0, 'bad' => 0];
+ if (!$dir) return $out;
+ $map = json_decode((string) get_setting('backup_check_map', ''), true);
+ $map = is_array($map) ? $map : [];
+ $files = glob($dir . '/backup_*.zip') ?: [];
+ usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
+ $stale = time() - $maxAgeDays * 86400;
+ foreach ($files as $f) {
+ if ($out['checked'] >= $limit) break;
+ $name = basename($f);
+ if (!preg_match('/^backup_[\d\-_a-f]+\.zip$/', $name)) continue;
+ $entry = $map[$name] ?? null;
+ if ($entry && strtotime((string) $entry['at']) > $stale && strtotime((string) $entry['at']) >= filemtime($f)) continue; // frisch geprüft
+ $r = backup_verify_and_record($f);
+ $out['checked']++;
+ if (empty($r['ok'])) $out['bad']++;
+ }
+ return $out;
+}
diff --git a/DeskHub-Webseite/includes/db.php b/DeskHub-Webseite/includes/db.php
index b3d1cc4a..8bc45e62 100644
--- a/DeskHub-Webseite/includes/db.php
+++ b/DeskHub-Webseite/includes/db.php
@@ -15,10 +15,38 @@ function config(): array
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
$config = array_replace_recursive($config, $extra);
}
+ // Nur für die Testläufe (tests/run_all.php setzt DESKHUB_TESTS_FORCE_SQLITE): nie die in config/config.php eingetragene Datenbank
+ // (zum Beispiel die Produktiv-MySQL) benutzen, sondern SQLite. In Test-Webservern nur mit einer vom Test gesetzten Datenbankdatei.
+ if (getenv('DESKHUB_TESTS_FORCE_SQLITE') === '1') {
+ $testDb = getenv('DESKHUB_SQLITE_PATH');
+ if (PHP_SAPI === 'cli' || ($testDb !== false && $testDb !== '' && $testDb !== ':memory:')) {
+ $config['db']['driver'] = 'sqlite';
+ $config['db']['sqlite_path'] = $testDb !== false && $testDb !== '' ? $testDb : ':memory:';
+ }
+ }
}
return $config;
}
+/**
+ * Die Datenbank ist nicht eingerichtet: Mit vorhandenem Installer dorthin weiterleiten, sonst eine verständliche Meldung.
+ * Auf der Kommandozeile (Cron, Skripte) wird ein Fehler geworfen.
+ */
+function app_not_installed(): never
+{
+ $message = 'DeskHub ist noch nicht eingerichtet: Die Datenbank fehlt. Bitte die Installation unter /install/ abschließen.';
+ if (PHP_SAPI === 'cli') throw new RuntimeException($message);
+ $installerOpen = is_file(__DIR__ . '/../install/index.php') && !is_file(__DIR__ . '/../install/.installed');
+ if ($installerOpen && !headers_sent()) {
+ header('Location: /install/');
+ exit;
+ }
+ http_response_code(503);
+ header('Content-Type: text/plain; charset=utf-8');
+ exit($message . "
+");
+}
+
function get_db(): PDO
{
static $pdo = null;
@@ -32,6 +60,13 @@ function get_db(): PDO
$dbPath = $cfg['sqlite_path'];
$needInit = !file_exists($dbPath) || filesize($dbPath) === 0;
+ // Eine SQLite-Datenbank wird nie nebenbei angelegt (z. B. durch den ersten Seitenaufruf mit einer mitgelieferten Konfiguration):
+ // Das macht allein der Installer, wenn SQLite gewählt wurde. Nur Tests und Entwickler, die den Pfad ausdrücklich per
+ // DESKHUB_SQLITE_PATH angeben, dürfen eine neue Datenbank erzeugen lassen.
+ if ($needInit && $dbPath !== ':memory:' && (string) getenv('DESKHUB_SQLITE_PATH') === '') {
+ app_not_installed();
+ }
+
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA foreign_keys = ON;');
diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php
index 6cbaf43a..7e2c7d77 100644
--- a/DeskHub-Webseite/includes/functions.php
+++ b/DeskHub-Webseite/includes/functions.php
@@ -1,354 +1,447 @@
-';
-}
-
-function check_honeypot(): void
-{
- // Wenn das versteckte Feld ausgefüllt wurde, ist es ein Bot
- if (!empty($_POST['hp_website'])) {
- http_response_code(400);
- die('Bad Request.');
- }
-}
-
-function verify_csrf(): void
-{
- $token = $_POST['csrf_token'] ?? '';
- if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
- http_response_code(403);
- die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.');
- }
- // Token nach Verbrauch rotieren
- $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
-}
-
-function flash(string $type, string $message): void
-{
- $_SESSION['flash'][] = ['type' => $type, 'message' => $message];
-}
-
-function get_flashes(): array
-{
- $flashes = $_SESSION['flash'] ?? [];
- unset($_SESSION['flash']);
- return $flashes;
-}
-
-/**
- * Merkt sich zusätzlich zur normalen (HTML-escapten) Flash-Meldung, welches Ticket gerade
- * durch einen anderen Mitarbeiter gesperrt war. flash() allein kann keinen Button
- * enthalten (die Nachricht wird per e() ausgegeben) – Admins bekommen darüber direkt
- * unter der Fehlermeldung eine "Sperre jetzt aufheben"-Aktion angezeigt (siehe
- * staff/_header.php), damit sie nicht bis zum automatischen Timeout warten müssen.
- * Wird wie flash() einmalig ausgelesen und danach verworfen.
- */
-function flash_locked_ticket(string $ticketNumber, string $lockedByName): void
-{
- $_SESSION['locked_ticket_override'] = ['number' => $ticketNumber, 'name' => $lockedByName];
-}
-
-function get_locked_ticket_override(): ?array
-{
- $data = $_SESSION['locked_ticket_override'] ?? null;
- unset($_SESSION['locked_ticket_override']);
- return $data;
-}
-
-/** Erzeugt eine eindeutige, gut lesbare Ticketnummer, z.B. 251023-0001 */
-function generate_ticket_number(PDO $pdo): string
-{
- $prefix = date('ymd');
- do {
- $number = $prefix . '-' . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
- $stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE ticket_number = ?');
- $stmt->execute([$number]);
- $exists = (int) $stmt->fetchColumn() > 0;
- } while ($exists);
-
- return $number;
-}
-
-function claim_ticket_for_customer(PDO $pdo, int $ticketId, array $user): bool
-{
- $userId = (int) ($user['id'] ?? 0);
- $email = trim((string) ($user['email'] ?? ''));
- if ($ticketId < 1 || $userId < 1 || $email === '') {
- return false;
- }
-
- $stmt = $pdo->prepare(
- 'UPDATE tickets SET user_id = ?
- WHERE id = ? AND (user_id IS NULL OR user_id = ?) AND LOWER(requester_email) = LOWER(?)'
- );
- $stmt->execute([$userId, $ticketId, $userId, $email]);
-
- return $stmt->rowCount() === 1;
-}
-
-/**
- * Liefert ein dauerhaftes, zufälliges Zugriffstoken für einen Ticket-Link.
- * Das Klartext-Token wird verschlüsselt gespeichert und nur sein Hash zur Prüfung verwendet.
- */
-function ticket_access_token(int $ticketId): string
-{
- $pdo = get_db();
- $stmt = $pdo->prepare(
- 'SELECT token_hash, token_enc FROM ticket_access_tokens
- WHERE ticket_id = ? AND revoked_at IS NULL ORDER BY id DESC LIMIT 1'
- );
- $stmt->execute([$ticketId]);
- $stored = $stmt->fetch();
-
- if ($stored) {
- $token = decrypt_secret($stored['token_enc']);
- if (preg_match('/^[a-f0-9]{96}$/', $token) && hash_equals($stored['token_hash'], hash('sha256', $token))) {
- return $token;
- }
- }
-
- $token = bin2hex(random_bytes(48));
- $pdo->prepare(
- 'INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at)
- VALUES (?,?,?,?)'
- )->execute([$ticketId, hash('sha256', $token), encrypt_secret($token), db_now()]);
-
- return $token;
-}
-
-function verify_ticket_access_token(int $ticketId, string $token): bool
-{
- if (!preg_match('/^[a-f0-9]{96}$/', $token)) {
- return false;
- }
-
- $stmt = get_db()->prepare(
- 'SELECT token_hash FROM ticket_access_tokens
- WHERE ticket_id = ? AND token_hash = ? AND revoked_at IS NULL LIMIT 1'
- );
- $stmt->execute([$ticketId, hash('sha256', $token)]);
- $storedHash = $stmt->fetchColumn();
-
- return is_string($storedHash) && hash_equals($storedHash, hash('sha256', $token));
-}
-
-function ticket_access_url(array $ticket): string
-{
- $ticketId = (int) ($ticket['id'] ?? 0);
- if ($ticketId < 1) {
- $stmt = get_db()->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
- $stmt->execute([$ticket['ticket_number'] ?? '']);
- $ticketId = (int) $stmt->fetchColumn();
- }
- if ($ticketId < 1) {
- throw new RuntimeException('Ticket für den Kunden-Zugriffslink nicht gefunden.');
- }
-
- return base_url() . '/ticket_view.php?n=' . urlencode($ticket['ticket_number'])
- . '&token=' . urlencode(ticket_access_token($ticketId));
-}
-
-/**
- * Berechnet SLA-Fälligkeiten anhand einer Prioritäts-Zeile (response_hours/resolution_hours).
- *
- * Stunden werden auf max. 5 Jahre gekappt (SLA_MAX_HOURS): ein versehentlich riesiger oder
- * negativer Wert bei einer Priorität (z.B. Millionen statt normaler Stundenzahl, oder ein
- * Tippfehler mit zu vielen Nullen) würde sonst ein Datum weit außerhalb dessen ergeben, was
- * die DATETIME-Spalte speichern kann (MySQL wirft dann eine harte SQL-Exception und die
- * komplette Ticket-/Chat-Erstellung schlägt fehl – live an genau dieser Stelle beobachtet).
- */
-function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentId = null, ?int $helpTopicId = null): array
-{
- $slaMaxHours = 43800; // 5 Jahre – weit über jedem sinnvollen SLA-Wert, aber sicher innerhalb DATETIME-Grenzen
- $from = $from ?? db_now();
- // Ein zugeordneter SLA-Plan (Hilfethema vor Abteilung) ersetzt die Zeiten der Priorität
- if ($plan = sla_plan_for(get_db(), $departmentId, $helpTopicId)) {
- $priority['response_hours'] = $plan['response_hours'];
- $priority['resolution_hours'] = $plan['resolution_hours'];
- }
- $responseHours = max(0, min($slaMaxHours, (int) $priority['response_hours']));
- $resolutionHours = max(0, min($slaMaxHours, (int) $priority['resolution_hours']));
- if (get_setting('business_hours_enabled', '0') === '1') {
- return [
- 'first_response_due_at' => add_business_hours($from, $responseHours),
- 'resolution_due_at' => add_business_hours($from, $resolutionHours),
- ];
- }
- return [
- 'first_response_due_at' => add_hours($from, $responseHours),
- 'resolution_due_at' => add_hours($from, $resolutionHours),
- ];
-}
-
-/**
- * Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
- * null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
- */
-function sla_plan_for(PDO $pdo, ?int $departmentId, ?int $helpTopicId): ?array
-{
- $ids = [];
- if ($helpTopicId) {
- $s = $pdo->prepare('SELECT sla_plan_id FROM help_topics WHERE id = ?');
- $s->execute([$helpTopicId]);
- $ids[] = $s->fetchColumn();
- }
- if ($departmentId) {
- $s = $pdo->prepare('SELECT sla_plan_id FROM departments WHERE id = ?');
- $s->execute([$departmentId]);
- $ids[] = $s->fetchColumn();
- }
- $stmt = $pdo->prepare('SELECT * FROM sla_plans WHERE id = ? AND is_active = 1');
- foreach ($ids as $id) {
- if (!$id) continue;
- $stmt->execute([(int) $id]);
- if ($plan = $stmt->fetch()) return $plan;
- }
- return null;
-}
-
-function slugify(string $text): string
-{
- $text = preg_replace('~[^\pL\d]+~u', '-', $text);
- $text = trim($text, '-');
- $text = iconv('UTF-8', 'ASCII//TRANSLIT', $text) ?: $text;
- $text = strtolower($text);
- $text = preg_replace('~[^-a-z0-9]+~', '', $text);
- return $text !== '' ? $text : 'n-a';
-}
-
-/** Statuslabel + Badge-Farbe für die Oberfläche */
-function status_label(string $status): array
-{
- $map = [
- 'open' => ['Offen', '#2563eb'],
- 'pending' => ['Wartet auf Kunde', '#a855f7'],
- 'answered' => ['Beantwortet', '#0d9488'],
- 'on_hold' => ['Pausiert', '#6b7280'],
- 'resolved' => ['Gelöst', '#16a34a'],
- 'closed' => ['Geschlossen', '#374151'],
- ];
- return $map[$status] ?? [$status, '#888888'];
-}
-
-function format_dt(?string $dt): string
-{
- if (!$dt) return '–';
- return date('d.m.Y H:i', strtotime($dt));
-}
-
-function time_until(?string $dt): string
-{
- if (!$dt) return '–';
- $diff = strtotime($dt) - time();
- $abs = abs($diff);
- $hours = floor($abs / 3600);
- $mins = floor(($abs % 3600) / 60);
- $label = $hours > 0 ? "{$hours} Std {$mins} Min" : "{$mins} Min";
- return $diff >= 0 ? "in {$label}" : "vor {$label} (überfällig)";
-}
-
-function base_url(): string
-{
- return rtrim(get_setting('site_url', config()['site']['url'] ?? 'http://localhost'), '/');
-}
-
-/**
- * Baut ein sicheres SQL-Filterfragment für eine Abteilungs-Zugriffsbeschränkung.
- * $deptIds === null -> kein Filter (Admin, voller Zugriff): ['', []]
- * $deptIds === [] -> kein Zugriff auf irgendeine Abteilung: ['AND 1=0', []]
- * $deptIds === [1,2] -> ['AND col IN (?,?)', [1,2]]
- */
-function dept_filter_sql(?array $deptIds, string $column): array
-{
- if ($deptIds === null) {
- return ['', []];
- }
- if (count($deptIds) === 0) {
- return [' AND 1=0', []];
- }
- $placeholders = implode(',', array_fill(0, count($deptIds), '?'));
- return [" AND {$column} IN ({$placeholders})", $deptIds];
-}
-
-/** Liste der Abteilungs-IDs, auf die ein Mitarbeiter Zugriff hat (Admins: null = alle) */
-/** Darf dieser Mitarbeiter Tickets der Abteilung sehen? Admins alle, alle anderen nur ihre Abteilungen. */
-function staff_can_access_department(array $staff, int $departmentId): bool
-{
- $ids = staff_department_ids($staff);
- return $ids === null || in_array($departmentId, $ids, true);
-}
-
-function staff_department_ids(array $staff): ?array
-{
- if ($staff['role'] === 'admin') {
- return null; // null = kein Filter, voller Zugriff
- }
- $ids = [];
- if (!empty($staff['department_id'])) {
- $ids[] = (int) $staff['department_id'];
- }
- $stmt = get_db()->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
- $stmt->execute([$staff['id']]);
- foreach ($stmt->fetchAll() as $row) {
- $ids[] = (int) $row['department_id'];
- }
- return array_values(array_unique($ids));
-}
-
-/** Löscht ein Ticket dauerhaft, protokolliert es vorher für Berichte */
-function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void
-{
- $stmt = $pdo->prepare(
- 'INSERT INTO deleted_tickets_log (ticket_number, department_id, priority_id, help_topic_id, assigned_staff_id, deleted_by, deleted_at)
- VALUES (?,?,?,?,?,?,?)'
- );
- $stmt->execute([
- $ticket['ticket_number'],
- $ticket['department_id'] ?? null,
- $ticket['priority_id'] ?? null,
- $ticket['help_topic_id'] ?? null,
- $ticket['assigned_staff_id'] ?? null,
- $deletedByStaffId,
- db_now(),
- ]);
- $pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]);
-}
-
+';
+}
+
+function check_honeypot(): void
+{
+ // Wenn das versteckte Feld ausgefüllt wurde, ist es ein Bot
+ if (!empty($_POST['hp_website'])) {
+ http_response_code(400);
+ die('Bad Request.');
+ }
+}
+
+function verify_csrf(): void
+{
+ $token = $_POST['csrf_token'] ?? '';
+ if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
+ http_response_code(403);
+ die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.');
+ }
+ // Token nach Verbrauch rotieren
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+}
+
+function flash(string $type, string $message): void
+{
+ $_SESSION['flash'][] = ['type' => $type, 'message' => $message];
+}
+
+function get_flashes(): array
+{
+ $flashes = $_SESSION['flash'] ?? [];
+ unset($_SESSION['flash']);
+ return $flashes;
+}
+
+/**
+ * Merkt sich zusätzlich zur normalen (HTML-escapten) Flash-Meldung, welches Ticket gerade
+ * durch einen anderen Mitarbeiter gesperrt war. flash() allein kann keinen Button
+ * enthalten (die Nachricht wird per e() ausgegeben) – Admins bekommen darüber direkt
+ * unter der Fehlermeldung eine "Sperre jetzt aufheben"-Aktion angezeigt (siehe
+ * staff/_header.php), damit sie nicht bis zum automatischen Timeout warten müssen.
+ * Wird wie flash() einmalig ausgelesen und danach verworfen.
+ */
+function flash_locked_ticket(string $ticketNumber, string $lockedByName): void
+{
+ $_SESSION['locked_ticket_override'] = ['number' => $ticketNumber, 'name' => $lockedByName];
+}
+
+function get_locked_ticket_override(): ?array
+{
+ $data = $_SESSION['locked_ticket_override'] ?? null;
+ unset($_SESSION['locked_ticket_override']);
+ return $data;
+}
+
+/** Erzeugt eine eindeutige, gut lesbare Ticketnummer, z.B. 251023-0001 */
+function generate_ticket_number(PDO $pdo): string
+{
+ $prefix = date('ymd');
+ do {
+ $number = $prefix . '-' . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
+ $stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE ticket_number = ?');
+ $stmt->execute([$number]);
+ $exists = (int) $stmt->fetchColumn() > 0;
+ } while ($exists);
+
+ return $number;
+}
+
+function claim_ticket_for_customer(PDO $pdo, int $ticketId, array $user): bool
+{
+ $userId = (int) ($user['id'] ?? 0);
+ $email = trim((string) ($user['email'] ?? ''));
+ if ($ticketId < 1 || $userId < 1 || $email === '') {
+ return false;
+ }
+
+ $stmt = $pdo->prepare(
+ 'UPDATE tickets SET user_id = ?
+ WHERE id = ? AND (user_id IS NULL OR user_id = ?) AND LOWER(requester_email) = LOWER(?)'
+ );
+ $stmt->execute([$userId, $ticketId, $userId, $email]);
+
+ return $stmt->rowCount() === 1;
+}
+
+/**
+ * Liefert ein dauerhaftes, zufälliges Zugriffstoken für einen Ticket-Link.
+ * Das Klartext-Token wird verschlüsselt gespeichert und nur sein Hash zur Prüfung verwendet.
+ */
+function ticket_access_token(int $ticketId): string
+{
+ $pdo = get_db();
+ $stmt = $pdo->prepare(
+ 'SELECT token_hash, token_enc FROM ticket_access_tokens
+ WHERE ticket_id = ? AND revoked_at IS NULL ORDER BY id DESC LIMIT 1'
+ );
+ $stmt->execute([$ticketId]);
+ $stored = $stmt->fetch();
+
+ if ($stored) {
+ $token = decrypt_secret($stored['token_enc']);
+ if (preg_match('/^[a-f0-9]{96}$/', $token) && hash_equals($stored['token_hash'], hash('sha256', $token))) {
+ return $token;
+ }
+ }
+
+ $token = bin2hex(random_bytes(48));
+ $pdo->prepare(
+ 'INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at)
+ VALUES (?,?,?,?)'
+ )->execute([$ticketId, hash('sha256', $token), encrypt_secret($token), db_now()]);
+
+ return $token;
+}
+
+function verify_ticket_access_token(int $ticketId, string $token): bool
+{
+ if (!preg_match('/^[a-f0-9]{96}$/', $token)) {
+ return false;
+ }
+
+ $stmt = get_db()->prepare(
+ 'SELECT token_hash FROM ticket_access_tokens
+ WHERE ticket_id = ? AND token_hash = ? AND revoked_at IS NULL LIMIT 1'
+ );
+ $stmt->execute([$ticketId, hash('sha256', $token)]);
+ $storedHash = $stmt->fetchColumn();
+
+ return is_string($storedHash) && hash_equals($storedHash, hash('sha256', $token));
+}
+
+function ticket_access_url(array $ticket): string
+{
+ $ticketId = (int) ($ticket['id'] ?? 0);
+ if ($ticketId < 1) {
+ $stmt = get_db()->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
+ $stmt->execute([$ticket['ticket_number'] ?? '']);
+ $ticketId = (int) $stmt->fetchColumn();
+ }
+ if ($ticketId < 1) {
+ throw new RuntimeException('Ticket für den Kunden-Zugriffslink nicht gefunden.');
+ }
+
+ return base_url() . '/ticket_view.php?n=' . urlencode($ticket['ticket_number'])
+ . '&token=' . urlencode(ticket_access_token($ticketId));
+}
+
+/**
+ * Berechnet SLA-Fälligkeiten anhand einer Prioritäts-Zeile (response_hours/resolution_hours).
+ *
+ * Stunden werden auf max. 5 Jahre gekappt (SLA_MAX_HOURS): ein versehentlich riesiger oder
+ * negativer Wert bei einer Priorität (z.B. Millionen statt normaler Stundenzahl, oder ein
+ * Tippfehler mit zu vielen Nullen) würde sonst ein Datum weit außerhalb dessen ergeben, was
+ * die DATETIME-Spalte speichern kann (MySQL wirft dann eine harte SQL-Exception und die
+ * komplette Ticket-/Chat-Erstellung schlägt fehl – live an genau dieser Stelle beobachtet).
+ */
+function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentId = null, ?int $helpTopicId = null): array
+{
+ $slaMaxHours = 43800; // 5 Jahre – weit über jedem sinnvollen SLA-Wert, aber sicher innerhalb DATETIME-Grenzen
+ $from = $from ?? db_now();
+ // Ein zugeordneter SLA-Plan (Hilfethema vor Abteilung) ersetzt die Zeiten der Priorität
+ if ($plan = sla_plan_for(get_db(), $departmentId, $helpTopicId)) {
+ $priority['response_hours'] = $plan['response_hours'];
+ $priority['resolution_hours'] = $plan['resolution_hours'];
+ }
+ $responseHours = max(0, min($slaMaxHours, (int) $priority['response_hours']));
+ $resolutionHours = max(0, min($slaMaxHours, (int) $priority['resolution_hours']));
+ if (get_setting('business_hours_enabled', '0') === '1') {
+ return [
+ 'first_response_due_at' => add_business_hours($from, $responseHours),
+ 'resolution_due_at' => add_business_hours($from, $resolutionHours),
+ ];
+ }
+ return [
+ 'first_response_due_at' => add_hours($from, $responseHours),
+ 'resolution_due_at' => add_hours($from, $resolutionHours),
+ ];
+}
+
+/**
+ * Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
+ * null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
+ */
+function sla_plan_for(PDO $pdo, ?int $departmentId, ?int $helpTopicId): ?array
+{
+ $ids = [];
+ if ($helpTopicId) {
+ $s = $pdo->prepare('SELECT sla_plan_id FROM help_topics WHERE id = ?');
+ $s->execute([$helpTopicId]);
+ $ids[] = $s->fetchColumn();
+ }
+ if ($departmentId) {
+ $s = $pdo->prepare('SELECT sla_plan_id FROM departments WHERE id = ?');
+ $s->execute([$departmentId]);
+ $ids[] = $s->fetchColumn();
+ }
+ $stmt = $pdo->prepare('SELECT * FROM sla_plans WHERE id = ? AND is_active = 1');
+ foreach ($ids as $id) {
+ if (!$id) continue;
+ $stmt->execute([(int) $id]);
+ if ($plan = $stmt->fetch()) return $plan;
+ }
+ return null;
+}
+
+function slugify(string $text): string
+{
+ $text = preg_replace('~[^\pL\d]+~u', '-', $text);
+ $text = trim($text, '-');
+ $text = iconv('UTF-8', 'ASCII//TRANSLIT', $text) ?: $text;
+ $text = strtolower($text);
+ $text = preg_replace('~[^-a-z0-9]+~', '', $text);
+ return $text !== '' ? $text : 'n-a';
+}
+
+/** Statuslabel + Badge-Farbe für die Oberfläche */
+function status_label(string $status): array
+{
+ $map = [
+ 'open' => ['Offen', '#2563eb'],
+ 'pending' => ['Wartet auf Kunde', '#a855f7'],
+ 'answered' => ['Beantwortet', '#0d9488'],
+ 'on_hold' => ['Pausiert', '#6b7280'],
+ 'resolved' => ['Gelöst', '#16a34a'],
+ 'closed' => ['Geschlossen', '#374151'],
+ ];
+ return $map[$status] ?? [$status, '#888888'];
+}
+
+function format_dt(?string $dt): string
+{
+ if (!$dt) return '–';
+ return date('d.m.Y H:i', strtotime($dt));
+}
+
+function time_until(?string $dt): string
+{
+ if (!$dt) return '–';
+ $diff = strtotime($dt) - time();
+ $abs = abs($diff);
+ $hours = floor($abs / 3600);
+ $mins = floor(($abs % 3600) / 60);
+ $label = $hours > 0 ? "{$hours} Std {$mins} Min" : "{$mins} Min";
+ return $diff >= 0 ? "in {$label}" : "vor {$label} (überfällig)";
+}
+
+/**
+ * Die zur Wahl stehenden Signaturen für eine Antwort in einem Ticket (Web, Windows- und Android-App):
+ * eigene und die der Abteilung (bereinigtes HTML), Abteilungsname und die Voreinstellung ('own', 'dept' oder 'none').
+ *
+ * @return array{own: string, dept: string, deptName: string, default: string}
+ */
+function reply_signature_options(PDO $pdo, array $staff, int $departmentId): array
+{
+ $own = trim((string) ($staff['signature'] ?? ''));
+ $stmt = $pdo->prepare('SELECT name, signature FROM departments WHERE id = ?');
+ $stmt->execute([$departmentId]);
+ $row = $stmt->fetch() ?: ['name' => '', 'signature' => ''];
+ $dept = trim((string) $row['signature']);
+ return [
+ 'own' => $own !== '' ? sanitize_staff_html($own) : '',
+ 'dept' => $dept !== '' ? sanitize_staff_html($dept) : '',
+ 'deptName' => (string) $row['name'],
+ 'default' => $own !== '' ? 'own' : ($dept !== '' ? 'dept' : 'none'),
+ ];
+}
+
+/**
+ * Bestimmt die Signatur, die an eine öffentliche Antwort angehängt wird.
+ * $choice: 'own' = Signatur des Mitarbeiters, 'dept' = Signatur der Abteilung des Tickets, 'none' = keine,
+ * sonst (oder leer) das bisherige Verhalten: eigene Signatur, ersatzweise die der Abteilung.
+ * Gibt bereinigtes HTML zurück.
+ */
+function resolve_reply_signature(PDO $pdo, array $staff, int $departmentId, ?string $choice = null): string
+{
+ $own = trim((string) ($staff['signature'] ?? ''));
+ $deptStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
+ $deptStmt->execute([$departmentId]);
+ $dept = trim((string) $deptStmt->fetchColumn());
+ $signature = match ($choice) {
+ 'own' => $own,
+ 'dept' => $dept,
+ 'none' => '',
+ default => $own !== '' ? $own : $dept,
+ };
+ return $signature === '' ? '' : sanitize_staff_html($signature);
+}
+
+/** Unterstützte Favicon-Formate: Endung => MIME-Typ. */
+function favicon_types(): array
+{
+ return ['ico' => 'image/x-icon', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml'];
+}
+
+/** Prüft, ob die Datei wirklich ein Bild des angegebenen Formats ist (nicht nur die Endung). Gibt null oder eine Fehlermeldung zurück. */
+function favicon_validate_file(string $path, string $ext): ?string
+{
+ $head = (string) @file_get_contents($path, false, null, 0, 16);
+ switch ($ext) {
+ case 'ico':
+ return str_starts_with($head, "\x00\x00\x01\x00") || str_starts_with($head, "\x00\x00\x02\x00") ? null : 'Das ist keine gültige ICO-Datei.';
+ case 'png': return str_starts_with($head, "\x89PNG") ? null : 'Das ist keine gültige PNG-Datei.';
+ case 'jpg':
+ case 'jpeg': return str_starts_with($head, "\xFF\xD8\xFF") ? null : 'Das ist keine gültige JPG-Datei.';
+ case 'gif': return str_starts_with($head, 'GIF8') ? null : 'Das ist keine gültige GIF-Datei.';
+ case 'webp': return str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP' ? null : 'Das ist keine gültige WebP-Datei.';
+ case 'svg':
+ $svg = (string) @file_get_contents($path, false, null, 0, 1048576);
+ if (!preg_match('/