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('/-Tags für das Browser-Symbol (Tab, Lesezeichen, Startbildschirm). Ein eigenes Favicon (Einstellungen → Design) ersetzt alle + * Standard-Symbole, damit der Browser nicht ein anderes auswählt. Ohne Favicon gelten die Standard-Symbole und, falls vorhanden, das Logo. + */ +function site_icon_links(): string +{ + $favicon = (string) get_setting('favicon_path', ''); + $types = favicon_types(); + if ($favicon !== '') { + $ext = strtolower(pathinfo((string) parse_url($favicon, PHP_URL_PATH), PATHINFO_EXTENSION)); + $type = $types[$ext] ?? 'image/png'; + $href = e($favicon); + $out = '' . "\n" . '' . "\n"; + $out .= in_array($ext, ['png', 'jpg', 'jpeg'], true) ? '' : ''; + return $out . "\n"; + } + $out = '' . "\n" . '' . "\n" + . '' . "\n"; + $logo = (string) get_setting('logo_path', ''); + if ($logo !== '') $out .= '' . "\n"; + return $out; +} + +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']]); +} + /** Prüft, ob eine IP in einer Liste aus Einzeladressen und CIDR-Bereichen (IPv4/IPv6) steht. */ function ip_in_list(string $ip, array $list): bool { @@ -404,1354 +497,1476 @@ function client_ip(): string return $remote; } -/** Schreibt einen Eintrag ins Admin-Aktionsprotokoll (Einstellungen, Stammdaten – getrennt vom Ticket-Aktivitätsprotokoll) */ -function audit_log(?int $staffId, string $action): void -{ - // Spalte ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein zu langer - // Wert sonst eine harte Exception statt einfach zu kürzen. - get_db()->prepare('INSERT INTO admin_audit_log (staff_id, action, created_at) VALUES (?,?,?)') - ->execute([$staffId, mb_substr($action, 0, 255), db_now()]); -} - -/** - * Einfache Rate-Begrenzung: prüft, ob unter $bucket/$identifier in den letzten - * $windowMinutes Minuten bereits $maxAttempts Einträge protokolliert wurden. - * Bei Erlaubnis wird der Versuch sofort selbst protokolliert (Aufruf = Verbrauch). - */ -function rate_limit_check(string $bucket, string $identifier, int $maxAttempts, int $windowMinutes): bool -{ - // Alte Einträge regelmäßig löschen (ca. 1% der Aufrufe) - if (random_int(1, 100) === 1) { - get_db()->prepare('DELETE FROM rate_limit_log WHERE created_at < ?') - ->execute([date('Y-m-d H:i:s', time() - 86400)]); - } - - $pdo = get_db(); - $since = date('Y-m-d H:i:s', time() - $windowMinutes * 60); - $stmt = $pdo->prepare('SELECT COUNT(*) FROM rate_limit_log WHERE bucket = ? AND identifier = ? AND created_at >= ?'); - $stmt->execute([$bucket, $identifier, $since]); - $count = (int) $stmt->fetchColumn(); - if ($count >= $maxAttempts) { - return false; - } - $pdo->prepare('INSERT INTO rate_limit_log (bucket, identifier, created_at) VALUES (?,?,?)') - ->execute([$bucket, $identifier, db_now()]); - return true; -} - -/** Erzeugt ein Passwort-Reset-Token, speichert nur dessen Hash, gibt das Klartext-Token zurück (für den Link) */ -function create_password_reset(string $accountType, string $email): string -{ - $pdo = get_db(); - // 48 Bytes = 96 hex chars – ausreichend Entropie gegen Brute-Force - $token = bin2hex(random_bytes(48)); - // Alte, ungenutzte Tokens für diese E-Mail ungültig machen - $pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND email = ? AND used_at IS NULL') - ->execute([db_now(), $accountType, $email]); - $pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)') - ->execute([$accountType, $email, hash('sha256', $token), date('Y-m-d H:i:s', time() + 3600), db_now()]); - return $token; -} - -/** - * Prüft ob ein Passwort die Mindestanforderungen erfüllt. - * Gibt null zurück wenn OK, sonst eine Fehlermeldung. - */ -function validate_password_strength(string $password): ?string -{ - if (strlen($password) < 10) { - return 'Das Passwort muss mindestens 10 Zeichen lang sein.'; - } - if (!preg_match('/[A-Z]/', $password)) { - return 'Das Passwort muss mindestens einen Großbuchstaben enthalten.'; - } - if (!preg_match('/[a-z]/', $password)) { - return 'Das Passwort muss mindestens einen Kleinbuchstaben enthalten.'; - } - if (!preg_match('/[0-9]/', $password)) { - return 'Das Passwort muss mindestens eine Ziffer enthalten.'; - } - if (!preg_match('/[^A-Za-z0-9]/', $password)) { - return 'Das Passwort muss mindestens ein Sonderzeichen enthalten (!@#$%&* usw.).'; - } - // Triviale Passwörter blockieren - $trivial = ['password','passwort','12345678','123456789','1234567890','qwertzui','admin123','welcome1']; - if (in_array(strtolower($password), $trivial, true)) { - return 'Dieses Passwort ist zu häufig verwendet. Bitte wählen Sie ein sichereres Passwort.'; - } - return null; -} - -/** - * Verschlüsselt einen sensiblen Wert (z.B. SMTP-Passwort) mit AES-256-GCM. - * Schlüssel kommt aus config app_key oder wird aus DB-Credentials abgeleitet. - */ -function encrypt_secret(string $plaintext): string -{ - $key = _get_app_key(); - $iv = random_bytes(12); - $tag = ''; - $encrypted = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); - return base64_encode($iv . $tag . $encrypted); -} - -/** Entschlüsselt einen mit encrypt_secret() verschlüsselten Wert. */ -function decrypt_secret(string $ciphertext): string -{ - if ($ciphertext === '') return ''; - // Legacy: wenn nicht base64-verschlüsselt, Klartext zurückgeben (Migration) - $raw = base64_decode($ciphertext, true); - if ($raw === false || strlen($raw) < 28) return $ciphertext; - $key = _get_app_key(); - $iv = substr($raw, 0, 12); - $tag = substr($raw, 12, 16); - $encrypted = substr($raw, 28); - $decrypted = openssl_decrypt($encrypted, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); - return $decrypted !== false ? $decrypted : $ciphertext; // Fallback auf Klartext -} - -/** Interner Schlüssel für Verschlüsselung – 32 Bytes. */ -/** - * Interner Schlüssel für AES-256-GCM-Verschlüsselung – 32 Bytes. - * - * Priorität: - * 1. settings-Tabelle (key = 'app_key') → überlebt Updates ohne config.php-Änderung - * 2. config.php → app_key → gesetzt vom Installer oder manuell - * 3. Fallback aus DB-Credentials → schwach, aber nie null (Warnung wird angezeigt) - */ -function _get_app_key(): string -{ - // 1. DB-Setting hat höchste Priorität (überlebt Updates) - try { - $stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); - $stmt->execute(['app_key']); - $dbKey = $stmt->fetchColumn(); - if ($dbKey !== false && $dbKey !== '') { - return substr(hash('sha256', $dbKey, true), 0, 32); - } - } catch (\Throwable $e) { - // DB noch nicht verfügbar (z.B. während Installation) → weiter zum Fallback - } - - // 2. config.php - $cfg = config(); - if (!empty($cfg['app_key'])) { - return substr(hash('sha256', $cfg['app_key'], true), 0, 32); - } - - // 3. Schwacher Fallback (Warnung wird im Admin-Header angezeigt) - $seed = ($cfg['db']['pass'] ?? '') . ($cfg['db']['name'] ?? '') . ($cfg['db']['host'] ?? 'localhost'); - return substr(hash('sha256', $seed . 'deskhub-secret', true), 0, 32); -} - -/** Setzt einen Rate-Limit-Zähler zurück (z.B. nach erfolgreichem Login) */ -function rate_limit_reset(string $action, string $key): void -{ - try { - get_db()->prepare('DELETE FROM rate_limit_log WHERE bucket = ? AND identifier = ?') - ->execute([$action, $key]); - } catch (Throwable $e) { /* ignorieren */ } -} - -/** Prüft ein Passwort-Reset-Token und gibt die zugehörige E-Mail-Adresse zurück, oder null wenn ungültig/abgelaufen/verbraucht */ -function verify_password_reset(string $accountType, string $token): ?string -{ - $pdo = get_db(); - $stmt = $pdo->prepare( - "SELECT * FROM password_resets WHERE account_type = ? AND token_hash = ? AND used_at IS NULL AND expires_at >= ? - ORDER BY id DESC LIMIT 1" - ); - $stmt->execute([$accountType, hash('sha256', $token), db_now()]); - $row = $stmt->fetch(); - return $row ? $row['email'] : null; -} - -/** Markiert ein Reset-Token als verbraucht (nach erfolgreichem Zurücksetzen) */ -function consume_password_reset(string $accountType, string $token): void -{ - $pdo = get_db(); - $pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND token_hash = ?') - ->execute([db_now(), $accountType, hash('sha256', $token)]); -} - -/** - * Wandelt eine kleine, sichere Teilmenge von Markdown in HTML um (für Ticket-Antworten). - * Escaped IMMER zuerst den kompletten Text, wendet danach nur feste Muster an – - * dadurch ist eingegebenes HTML niemals aktiv, unabhängig vom Absender. - */ -function markdown_to_html(string $text): string -{ - $text = trim($text); - if ($text === '') return ''; - - $escaped = e($text); - - // Code-Abschnitte `code` - $escaped = preg_replace('/`([^`\n]+)`/', '$1', $escaped); - // Fett **text** - $escaped = preg_replace('/\*\*([^\*\n]+)\*\*/', '$1', $escaped); - // Kursiv *text* - $escaped = preg_replace('/\*([^\*\n]+)\*/', '$1', $escaped); - // Links [text](https://...) - nur http(s) erlaubt - $escaped = preg_replace_callback('/\[([^\]\n]+)\]\((https?:\/\/[^\s\)]+)\)/', function ($m) { - return '' . $m[1] . ''; - }, $escaped); - - // Zeilen in Absätze/Listen umwandeln - $lines = preg_split('/\r?\n/', $escaped); - $html = ''; - $inList = false; - foreach ($lines as $line) { - if (preg_match('/^-\s+(.*)/', $line, $m)) { - if (!$inList) { $html .= ''; $inList = false; } - $html .= trim($line) === '' ? '
' : '

' . $line . '

'; - } - } - if ($inList) $html .= ''; - return $html; -} - -/** Schreibt einen Eintrag ins Aktivitätsprotokoll eines Tickets */ -function log_activity(PDO $pdo, int $ticketId, ?int $staffId, string $action): void -{ - $stmt = $pdo->prepare('INSERT INTO activity_log (ticket_id, staff_id, action, created_at) VALUES (?,?,?,?)'); - $stmt->execute([$ticketId, $staffId, $action, db_now()]); -} - -/** - * Sperre für Live-Chat-Tickets (source='chat'): verhindert, dass zwei Mitarbeiter - * unbemerkt gleichzeitig im selben Chat antworten – egal ob über das Web-Backend - * (staff/live_chat.php) oder die Mobile-App (api/staff/v1/ticket_message.php), beide - * teilen sich diese eine Logik, damit sie nicht auseinanderlaufen. Wer den Chat öffnet - * bzw. beantwortet, "besitzt" ihn für ein paar Minuten; danach läuft die Sperre - * automatisch ab (kein Hängenbleiben, wenn z.B. die App einfach geschlossen wird). - * Andere Mitarbeiter dürfen den Chat trotzdem weiter LESEN – nur das Schreiben wird - * ihnen verwehrt, siehe Aufrufer. - */ -const LIVE_CHAT_LOCK_TIMEOUT_MINUTES = 5; - -/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn - * frei, selbst gesperrt, oder die Sperre abgelaufen ist. */ -function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string -{ - $lockFresh = !empty($ticket['locked_at']) - && (time() - strtotime($ticket['locked_at'])) < LIVE_CHAT_LOCK_TIMEOUT_MINUTES * 60; - if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) { - return null; - } - $stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?'); - $stmt->execute([$ticket['locked_by']]); - return $stmt->fetchColumn() ?: 'ein anderer Mitarbeiter'; -} - -/** Übernimmt/erneuert die Sperre für $staffId (nur aufrufen, wenn nicht bereits - * frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */ -function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void -{ - $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') - ->execute([$staffId, db_now(), $ticketId]); -} - -/** - * Generelle Ticket-Sperre für ALLE Tickets (nicht Live-Chat-spezifisch, siehe oben) – - * verhindert, dass zwei Mitarbeiter unbemerkt gleichzeitig am selben Ticket arbeiten. - * Nutzt dieselben locked_by/locked_at-Spalten, aber eine "harte" Semantik: anders als - * beim Live-Chat wird hier nicht nur das Schreiben, sondern auch das Öffnen selbst - * verweigert (siehe Aufrufer in staff/ticket_view.php und api/staff/v1/ticket.php). - * Für Live-Chat-Tickets (source='chat') gilt weiterhin die separate, mildere - * live_chat_locked_by_other()-Logik – NIEMALS beide auf dasselbe Ticket anwenden, sonst - * würden sich die Timeouts widersprechen. - * (Ursprünglich 10 Minuten, auf Nutzerwunsch verkürzt: wer ein Ticket nur kurz öffnet und - * dann wegnavigiert ohne zu antworten/freizugeben, blockiert es sonst unnötig lange für - * andere. Admins können eine fremde Sperre zusätzlich jederzeit sofort aufheben, siehe - * flash_locked_ticket()/get_locked_ticket_override().) - */ -const TICKET_LOCK_TIMEOUT_MINUTES = 5; - -/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn - * frei, selbst gesperrt, oder die Sperre abgelaufen ist. */ -function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string -{ - $lockFresh = !empty($ticket['locked_at']) - && (time() - strtotime($ticket['locked_at'])) < TICKET_LOCK_TIMEOUT_MINUTES * 60; - if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) { - return null; - } - $stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?'); - $stmt->execute([$ticket['locked_by']]); - return $stmt->fetchColumn() ?: 'einem anderen Mitarbeiter'; -} - -/** Übernimmt/erneuert die generelle Ticket-Sperre (nur aufrufen, wenn nicht bereits - * frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */ -function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void -{ - $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') - ->execute([$staffId, db_now(), $ticketId]); -} - -/** Liest eine Einstellung aus der settings-Tabelle (z.B. Logo-Pfad) */ -/** Geteilter In-Request-Cache für get_setting / set_setting (per Referenz). */ -function &_settings_cache(): array -{ - static $cache = []; - return $cache; -} - -/** Liest eine Einstellung aus der DB (mit In-Request-Cache). */ -function get_setting(string $key, ?string $default = null): ?string -{ - $cache = &_settings_cache(); - if (array_key_exists($key, $cache)) { - return $cache[$key]; - } - $stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); - $stmt->execute([$key]); - $value = $stmt->fetchColumn(); - return $cache[$key] = ($value !== false ? $value : $default); -} - -/** - * Speichert eine Einstellung (atomares Upsert, treiber-unabhängig) - * und aktualisiert sofort den In-Request-Cache. - */ -function set_setting(string $key, ?string $value): void -{ - $pdo = get_db(); - $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - - if ($driver === 'sqlite') { - $pdo->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?,?)') - ->execute([$key, $value]); - } else { - $pdo->prepare( - 'INSERT INTO settings (setting_key, setting_value) VALUES (?,?) - ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)' - )->execute([$key, $value]); - } - - // Cache sofort aktualisieren – nachfolgende get_setting()-Aufrufe im selben - // Request liefern den neuen Wert ohne erneuten DB-Trip. - $cache = &_settings_cache(); - $cache[$key] = $value; -} - -/** Formatiert einen Zeitstempel als relative "vor X Min./Std./Tagen"-Angabe (für Cron-/Postfach-Status-Anzeigen). */ -function cron_last_run_label(?string $dt): string -{ - if (!$dt) return 'Noch nie'; - $mins = (int) round((time() - strtotime($dt)) / 60); - if ($mins < 1) return 'Gerade eben'; - if ($mins < 60) return "vor {$mins} Min."; - $h = (int) round($mins / 60); - if ($h < 48) return "vor {$h} Std."; - return 'vor ' . (int) round($h / 24) . ' Tagen'; -} - -/** - * Sorgt dafür, dass die E-Mail-Adresse einer Abteilung automatisch auch als - * Eintrag in der Routing-Tabelle (email_addresses) existiert, damit eingehende - * Mails an diese Adresse ohne separaten Schritt in der richtigen Abteilung - * landen (siehe staff/departments.php, staff/email_addresses.php). - * Bereits vorhandene Routing-Einträge für DIESELBE Adresse werden nie - * überschrieben – auch nicht, wenn sie auf eine andere Abteilung zeigen. - */ -function sync_department_email_route(PDO $pdo, int $departmentId, string $email): void -{ - $email = trim(strtolower($email)); - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return; - - $stmt = $pdo->prepare('SELECT id FROM email_addresses WHERE LOWER(email) = ?'); - $stmt->execute([$email]); - if ($stmt->fetchColumn()) { - return; // Adresse ist bereits geroutet (an diese oder eine andere Abteilung) – nicht anfassen - } - - $pdo->prepare('UPDATE email_addresses SET is_default = 0 WHERE department_id = ?')->execute([$departmentId]); - $pdo->prepare('INSERT INTO email_addresses (email, label, department_id, priority_id, is_default, created_at) VALUES (?,?,?,NULL,1,?)') - ->execute([$email, 'Automatisch (Abteilungs-E-Mail)', $departmentId, db_now()]); -} - -/** - * Abteilungen für Auswahllisten, in denen etwas NEU zugeordnet wird (Weiterleiten, Routing, - * Regeln, Hilfethemen …): nur aktive Abteilungen plus die bereits gewählten ($keepIds), damit - * bestehende Zuordnungen beim Bearbeiten nicht verloren gehen. - */ -function departments_for_select(PDO $pdo, array $keepIds = []): array -{ - $keep = array_values(array_filter(array_map('intval', $keepIds))); - $sql = 'SELECT * FROM departments WHERE is_active = 1'; - if ($keep) $sql .= ' OR id IN (' . implode(',', $keep) . ')'; - return $pdo->query($sql . ' ORDER BY name')->fetchAll(); -} - -/** Liefert die ID einer aktiven Abteilung: $id selbst, sonst die Standardabteilung, sonst irgendeine aktive. */ -function active_department_id(PDO $pdo, ?int $id): ?int -{ - if ($id) { - $stmt = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1'); - $stmt->execute([$id]); - if ((int) $stmt->fetchColumn()) return $id; - } - $fallback = $pdo->query('SELECT id FROM departments WHERE is_active = 1 ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn(); - return $fallback ? (int) $fallback : $id; -} - -/** - * Hilfethemen als flache, hierarchisch sortierte Liste (Eltern vor Kindern). - * Jede Zeile enthält zusätzlich 'path' ("Eltern / Kind") und 'depth'. - * Kunden sehen nur aktive, öffentliche Themen ($publicOnly), Mitarbeiter alle aktiven. - */ -function help_topic_options(PDO $pdo, bool $publicOnly = false, bool $activeOnly = true): array -{ - $rows = $pdo->query( - 'SELECT h.*, COALESCE(d.is_active, 1) AS dept_active FROM help_topics h LEFT JOIN departments d ON d.id = h.department_id ORDER BY h.sort_order, h.name' - )->fetchAll(); - $byParent = []; - $byId = []; - foreach ($rows as $r) { - $byId[(int) $r['id']] = $r; - $pid = !empty($r['parent_id']) ? (int) $r['parent_id'] : 0; - $byParent[$pid][] = $r; - } - $out = []; - $walk = function (int $parent, string $prefix, int $depth, array $seen) use (&$walk, &$out, $byParent, $publicOnly, $activeOnly) { - foreach ($byParent[$parent] ?? [] as $r) { - if (isset($seen[(int) $r['id']])) continue; // Schutz vor Zyklen - $path = $prefix === '' ? $r['name'] : $prefix . ' / ' . $r['name']; - $visible = (!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1)); - if ($visible) { - $r['path'] = $path; - $r['depth'] = $depth; - $out[] = $r; - } - $walk((int) $r['id'], $path, $depth + 1, $seen + [(int) $r['id'] => true]); - } - }; - $walk(0, '', 0, []); - // Themen, deren Elternthema nicht existiert, trotzdem anzeigen - $shown = array_column($out, 'id'); - foreach ($rows as $r) { - if (!empty($r['parent_id']) && !isset($byId[(int) $r['parent_id']]) && !in_array($r['id'], $shown, true)) { - if ((!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1))) { - $r['path'] = $r['name']; $r['depth'] = 0; $out[] = $r; - } - } - } - return $out; -} - -/** Gibt die erlaubten Upload-Dateiendungen zurück (aus DB-Setting oder config-Fallback). */ -function get_allowed_extensions(): array -{ - $setting = get_setting('upload_allowed_extensions', ''); - if ($setting) { - $exts = array_filter(array_map('trim', explode(',', strtolower($setting)))); - if ($exts) return array_values($exts); - } - return config()['uploads']['allowed_extensions']; -} - -/** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */ -function get_max_upload_size(): int -{ - $setting = get_setting('upload_max_size_mb', ''); - if ($setting && (int)$setting > 0) { - return (int)$setting * 1024 * 1024; - } - return config()['uploads']['max_size_bytes']; -} - -function handle_ticket_upload(array $file): array -{ - $cfg = config()['uploads']; - $allowed = get_allowed_extensions(); - $maxSize = get_max_upload_size(); - - if ($file['error'] !== UPLOAD_ERR_OK) { - return ['error' => 'Der Datei-Upload ist fehlgeschlagen.']; - } - if ($file['size'] > $maxSize) { - return ['error' => 'Die Datei ist zu groß (max. ' . round($maxSize / 1024 / 1024) . ' MB).']; - } - // Null-Byte im Dateinamen verhindern (PHP < 8 war anfällig) - if (strpos($file['name'], "\0") !== false) { - return ['error' => 'Ungültiger Dateiname.']; - } - // .htaccess und ähnliche Systemdateien explizit blockieren - $basename = strtolower(basename($file['name'])); - if (in_array($basename, ['.htaccess', '.htpasswd', 'web.config', '.env', 'php.ini'], true)) { - return ['error' => 'Dieser Dateiname ist nicht erlaubt.']; - } - // Endung prüfen (nur letzte Endung – verhindert doppelte wie shell.php.jpg) - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); - // Doppelte Endungen blockieren (z.B. evil.php.jpg) - $allParts = explode('.', strtolower($file['name'])); - // Auch wenn ein Admin die Liste der erlaubten Endungen erweitert: Typen, die ein Browser auf unserer Domain - // als Seite/Skript ausführen würde (Uploads sind direkt abrufbar), und ausführbare Dateien bleiben gesperrt. - $dangerousExts = ['php','php3','php4','php5','php7','php8','phtml','phar','asp','aspx','jsp','jspx','sh','py','pl','cgi','exe', - 'html','htm','xhtml','shtml','svg','svgz','xml','xsl','xslt','js','mjs','htc','swf','hta','jar','vbs','bat','cmd','ps1','msi','dll','com','scr','cer','crt']; - foreach ($allParts as $part) { - if (in_array($part, $dangerousExts, true)) { - return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.']; - } - } - if (!in_array($ext, $allowed, true)) { - return ['error' => 'Dieser Dateityp ist nicht erlaubt (' . e($ext) . '). Erlaubt: ' . implode(', ', $allowed)]; - } - // MIME-Typ per finfo prüfen (verhindert verschleierte Dateitypen) - $dangerousMimes = [ - 'text/x-php','application/x-php','application/php','text/php', - 'application/x-httpd-php','application/x-httpd-php-source', - 'text/x-script.php','application/x-sh','text/x-shellscript', - ]; - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mimeType = finfo_file($finfo, $file['tmp_name']); - finfo_close($finfo); - if (in_array($mimeType, $dangerousMimes, true)) { - return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.']; - } - } - // Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz - $storedName = bin2hex(random_bytes(16)) . '.' . $ext; - $uploadsDir = realpath(rtrim($cfg['path'], '/')); - $dest = $uploadsDir . '/' . $storedName; - if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) { - return ['error' => 'Ungültiger Upload-Pfad.']; - } - if (!move_uploaded_file($file['tmp_name'], $dest)) { - return ['error' => 'Die Datei konnte nicht gespeichert werden.']; - } - return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']]; -} - -/** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input). - * Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */ -function handle_ticket_uploads_multi(array $filesInput): array -{ - $results = []; - // $_FILES['attachments'] hat Struktur: ['name'=>[...], 'tmp_name'=>[...], ...] - $count = is_array($filesInput['name']) ? count($filesInput['name']) : 0; - for ($i = 0; $i < $count; $i++) { - if (empty($filesInput['name'][$i])) continue; - $single = [ - 'name' => $filesInput['name'][$i], - 'type' => $filesInput['type'][$i], - 'tmp_name' => $filesInput['tmp_name'][$i], - 'error' => $filesInput['error'][$i], - 'size' => $filesInput['size'][$i], - ]; - $results[] = handle_ticket_upload($single); - } - return $results; -} - -/** Gruppiert ein Aggregat-Ergebnis (Spalten "grp"/"c") als [Schlüssel => Anzahl]; "none" für NULL-Gruppe */ -function fetch_grouped_counts(PDO $pdo, string $sql, array $params): array -{ - $out = []; - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - foreach ($stmt->fetchAll() as $row) { - $key = $row['grp'] === null ? 'none' : (string) $row['grp']; - $out[$key] = (int) $row['c']; - } - return $out; -} - -function report_avg_response_hours_expression(string $driver): string -{ - return $driver === 'sqlite' - ? '(julianday(t.first_responded_at) - julianday(t.created_at)) * 24' - : 'TIMESTAMPDIFF(SECOND, t.created_at, t.first_responded_at) / 3600.0'; -} - -/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension */ -/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension. - * $deptIds: null = kein Filter (Admin, alle Abteilungen), Array = nur diese Abteilungen (abteilungsbeschränkte Mitarbeiter). */ -function build_report_table(PDO $pdo, string $dimColumn, string $from, string $to, ?array $deptIds = null): array -{ - $tCol = "t.{$dimColumn}"; - [$deptFilterT, $deptParamsT] = dept_filter_sql($deptIds, 't.department_id'); - [$deptFilterPlain, $deptParamsPlain] = dept_filter_sql($deptIds, 'department_id'); - - $geoeffnet = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); - $geschlossen = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.closed_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); - $zugewiesen = fetch_grouped_counts($pdo, - "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id - WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); - $ueberfaellig = fetch_grouped_counts($pdo, - "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id - WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); - $wiederGeoeffnet = fetch_grouped_counts($pdo, - "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id - WHERE al.action = 'Ticket wieder geöffnet' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); - $geloescht = fetch_grouped_counts($pdo, - "SELECT {$dimColumn} AS grp, COUNT(*) c FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterPlain} GROUP BY {$dimColumn}", [$from, $to, ...$deptParamsPlain]); - - $serviceTimes = []; - $stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.resolved_at FROM tickets t WHERE t.resolved_at BETWEEN ? AND ?{$deptFilterT}"); - $stmt->execute([$from, $to, ...$deptParamsT]); - foreach ($stmt->fetchAll() as $r) { - $key = $r['grp'] === null ? 'none' : (string) $r['grp']; - $serviceTimes[$key][] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600; - } - - $responseTimes = []; - $stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.first_responded_at FROM tickets t WHERE t.first_responded_at BETWEEN ? AND ?{$deptFilterT}"); - $stmt->execute([$from, $to, ...$deptParamsT]); - foreach ($stmt->fetchAll() as $r) { - $key = $r['grp'] === null ? 'none' : (string) $r['grp']; - $responseTimes[$key][] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600; - } - - $allKeys = array_unique(array_merge( - array_keys($geoeffnet), array_keys($geschlossen), array_keys($zugewiesen), - array_keys($ueberfaellig), array_keys($wiederGeoeffnet), array_keys($geloescht), - array_keys($serviceTimes), array_keys($responseTimes) - )); - - $rows = []; - foreach ($allKeys as $key) { - $rows[$key] = [ - 'geoeffnet' => $geoeffnet[$key] ?? 0, - 'zugewiesen' => $zugewiesen[$key] ?? 0, - 'ueberfaellig' => $ueberfaellig[$key] ?? 0, - 'geschlossen' => $geschlossen[$key] ?? 0, - 'wieder_geoeffnet' => $wiederGeoeffnet[$key] ?? 0, - 'geloescht' => $geloescht[$key] ?? 0, - 'servicezeit' => isset($serviceTimes[$key]) ? round(array_sum($serviceTimes[$key]) / count($serviceTimes[$key]), 1) : null, - 'reaktionszeit' => isset($responseTimes[$key]) ? round(array_sum($responseTimes[$key]) / count($responseTimes[$key]), 1) : null, - ]; - } - return $rows; -} - -/** - * Allowlist-Sanitizer für Rich-Text-HTML (Antworten, Wissensdatenbank, Vorlagen) und für HTML-E-Mails - * fremder Absender, die im Mitarbeiterbereich angezeigt werden. Nutzt bewusst keine DOM-Erweiterung - * (siehe README: keine zusätzlichen PHP-Erweiterungen nötig). - * - * Vorgehen: Der Text wird in Tags und Text zerlegt. Jedes Tag wird nur mit festem Namen und festen, - * neu geschriebenen Attributen ausgegeben; alles andere (auch ein nicht geschlossenes "<" im Text) - * wird als Text maskiert. Dadurch kann kein Rest-Markup entstehen, das sich mit nachfolgendem HTML - * der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities), - * weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert. - */ -function sanitize_staff_html(string $html): string -{ - if (trim($html) === '') return ''; - - // Gefährliche Elemente komplett inkl. Inhalt entfernen - $blocked = 'script|style|iframe|frame|frameset|object|embed|applet|link|meta|base|form|input|textarea|select|option|button|svg|math|template|noscript|title|head|xml'; - $html = preg_replace('/<(' . $blocked . ')\b[^>]*>.*?<\/\1\s*>/is', '', $html); - $html = preg_replace('/<\/?(' . $blocked . ')\b[^>]*>/is', '', $html); - // Kommentare, Doctype, bedingte Kommentare und Verarbeitungsanweisungen entfernen - $html = preg_replace('//s', '', $html); - $html = preg_replace('/<[!?][^>]*>/s', '', $html); - - $allowed = [ - 'b','strong','i','em','u','s','ul','ol','li','br','p','div','span','a', - 'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col', - 'h1','h2','h3','h4','h5','h6','hr','blockquote','pre','code','img', - 'figure','figcaption','small','sup','sub', - ]; - $styleTags = ['img','td','th','tr','table','div','span','p','blockquote']; - $extraAttrs = [ - 'img' => ['alt','width','height'], - 'td' => ['colspan','rowspan','align','valign','width'], - 'th' => ['colspan','rowspan','align','valign','width'], - 'table' => ['border','cellpadding','cellspacing','width'], - ]; - - // Attributwert lesen (Anführungszeichen Pflicht, Name nicht Teil eines anderen Namens), Entities auflösen - $attr = static function (string $raw, string $name): ?string { - if (!preg_match('/(?{}]|/\*|expression|javascript|behavior|-moz-binding|url\s*\(|image\s*\(|var\s*\(|attr\s*\(#i', $val)) continue; - $plain = preg_replace('/\b(?:rgb|rgba|hsl|hsla)\([0-9.,%\s\/]*\)/i', '', $val); - if (str_contains((string) $plain, '(') || strlen($val) > 120) continue; - $out[] = $prop . ': ' . $val; - } - return implode('; ', $out); - }; - - $e = static fn(string $v): string => htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - - $parts = preg_split('/(<[^<>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE); - $result = ''; - foreach ($parts as $i => $part) { - if ($part === '') continue; - if ($i % 2 === 0) { - // Text: nie ein "<" oder ">" durchlassen, das der Browser als Markup lesen könnte - $result .= str_replace(['<', '>'], ['<', '>'], $part); - continue; - } - if (!preg_match('/^<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)(\/?)>$/s', $part, $m)) { - $result .= str_replace(['<', '>'], ['<', '>'], $part); // kein Tag (z. B. "a < b und c > d"): als Text behalten - continue; - } - $tag = strtolower($m[2]); - if (!in_array($tag, $allowed, true)) continue; - if ($m[1] === '/') { $result .= ""; continue; } - $raw = $m[3]; - $out = ''; - - if ($tag === 'a') { - $href = $attr($raw, 'href'); - if ($href !== null && preg_match('#^(https?:|mailto:|cid:)#i', $href)) $out .= ' href="' . $e($href) . '"'; - $result .= ''; - continue; - } - if ($tag === 'img') { - $src = $attr($raw, 'src'); - if ($src !== null) { - // Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen - if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#i', $src)) { - $out .= ' src="' . $e($src) . '"'; - } else { - $out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"'; - } - } - } - foreach ($extraAttrs[$tag] ?? [] as $name) { - $v = $attr($raw, $name); - if ($v !== null) $out .= ' ' . $name . '="' . $e($v) . '"'; - } - if (in_array($tag, $styleTags, true)) { - $st = $attr($raw, 'style'); - if ($st !== null) { - $st = $cleanStyle($st); - if ($st !== '') $out .= ' style="' . $e($st) . '"'; - } - } - $result .= ($m[4] === '/') ? "<{$tag}{$out}/>" : "<{$tag}{$out}>"; - } - - return $result; -} - -/** - * Ersetzt Variablen in einem Canned-Response-Text. - * - * Verfügbare Variablen: - * {{kundename}} Name des Ticketersellers - * {{vorname}} Erster Teil des Namens - * {{email}} E-Mail des Kunden - * {{ticketnummer}} Ticket-Nummer (z.B. #532085) - * {{betreff}} Betreff des Tickets - * {{abteilung}} Name der Abteilung - * {{prioritaet}} Priorität - * {{status}} Aktueller Status - * {{mitarbeiter}} Name des zugewiesenen Mitarbeiters - * {{datum}} Heutiges Datum (TT.MM.JJJJ) - * {{uhrzeit}} Aktuelle Uhrzeit (HH:MM) - * {{signatur}} Signatur des eingeloggten Mitarbeiters - */ -function resolve_canned_vars(string $text, array $ticket, array $staff): string -{ - $name = $ticket['requester_name'] ?? ''; - $vorname = explode(' ', trim($name))[0] ?? $name; - - $vars = [ - '{{kundename}}' => $name, - '{{vorname}}' => $vorname, - '{{email}}' => $ticket['requester_email'] ?? '', - '{{ticketnummer}}' => '#' . ($ticket['ticket_number'] ?? ''), - '{{betreff}}' => $ticket['subject'] ?? '', - '{{abteilung}}' => $ticket['department_name'] ?? '', - '{{prioritaet}}' => $ticket['priority_name'] ?? '', - '{{status}}' => $ticket['status'] ?? '', - '{{mitarbeiter}}' => $ticket['staff_name'] ?? $staff['name'] ?? '', - '{{datum}}' => date('d.m.Y'), - '{{uhrzeit}}' => date('H:i'), - '{{signatur}}' => $staff['signature'] ?? '', - ]; - - return str_replace(array_keys($vars), array_values($vars), $text); -} - -/** - * Prüft ob ein Mitarbeiter aktuell verfügbar ist (nicht im Urlaub, - * Status nicht 'offline', und falls Schichten definiert: innerhalb der Schicht). - */ -function is_staff_available(PDO $pdo, int $staffId): bool -{ - // Urlaub - $s = $pdo->prepare('SELECT is_on_vacation FROM staff WHERE id = ?'); - $s->execute([$staffId]); - $row = $s->fetch(); - if (!$row || $row['is_on_vacation']) return false; - - // Echtzeit-Status - try { - $s = $pdo->prepare('SELECT status FROM staff_availability WHERE staff_id = ?'); - $s->execute([$staffId]); - $avail = $s->fetchColumn(); - if ($avail === 'offline') return false; - } catch (Throwable $e) { /* Tabelle noch nicht angelegt */ } - - // Schichtplan – nur prüfen wenn Schichten definiert sind - try { - $s = $pdo->prepare('SELECT COUNT(*) FROM staff_shifts WHERE staff_id = ? AND is_active = 1'); - $s->execute([$staffId]); - if ((int)$s->fetchColumn() > 0) { - $dow = (int)date('N'); // 1=Mo … 7=So - $time = date('H:i:s'); - $s = $pdo->prepare( - 'SELECT COUNT(*) FROM staff_shifts - WHERE staff_id = ? AND weekday = ? AND is_active = 1 - AND start_time <= ? AND end_time > ?' - ); - $s->execute([$staffId, $dow, $time, $time]); - if ((int)$s->fetchColumn() === 0) return false; // außerhalb Schicht - } - } catch (Throwable $e) { /* ignorieren */ } - - return true; -} - -/** Prüft, ob eine E-Mail-Adresse (per exaktem Treffer oder Domain) auf der Sperrliste steht */ -function is_banned(string $email): bool -{ - $email = trim(strtolower($email)); - if ($email === '') return false; - $domain = substr(strrchr($email, '@'), 1) ?: ''; - - $stmt = get_db()->prepare( - "SELECT 1 FROM banlist WHERE - (match_type = 'email' AND LOWER(value) = ?) - OR (match_type = 'domain' AND LOWER(value) = ?) - LIMIT 1" - ); - $stmt->execute([$email, $domain]); - return (bool) $stmt->fetchColumn(); -} - -/** - * Wertet alle aktiven Ticket-Regeln (sort_order) gegen die Daten eines neuen Tickets aus - * und liefert die anzuwendenden Aktionen zurück. $ticketData braucht die Schlüssel: - * subject, message, requester_email, requester_name, source. - * Sobald eine Regel mit der Aktion "reject" zutrifft, werden keine weiteren Regeln mehr geprüft. - */ -function evaluate_ticket_rules(PDO $pdo, array $ticketData): array -{ - $result = [ - 'reject' => false, - 'set_department' => null, - 'set_priority' => null, - 'set_help_topic' => null, - 'assign_staff' => null, - 'add_tags' => [], - 'auto_reply' => null, - 'matched_rules' => [], - ]; - - $rules = $pdo->query('SELECT * FROM ticket_rules WHERE is_active = 1 ORDER BY sort_order, id')->fetchAll(); - - foreach ($rules as $rule) { - $condStmt = $pdo->prepare('SELECT * FROM ticket_rule_conditions WHERE rule_id = ?'); - $condStmt->execute([$rule['id']]); - $conditions = $condStmt->fetchAll(); - if (!$conditions) continue; - - $matchAll = $rule['match_type'] === 'all'; - $matched = $matchAll; // bei "all" optimistisch starten, bei "any" pessimistisch - foreach ($conditions as $cond) { - $fieldValue = mb_strtolower((string) ($ticketData[$cond['field']] ?? '')); - $condValue = mb_strtolower($cond['value']); - $hit = match ($cond['operator']) { - 'contains' => $condValue !== '' && str_contains($fieldValue, $condValue), - 'not_contains' => $condValue === '' || !str_contains($fieldValue, $condValue), - 'equals' => $fieldValue === $condValue, - 'starts_with' => str_starts_with($fieldValue, $condValue), - 'ends_with' => str_ends_with($fieldValue, $condValue), - default => false, - }; - if ($matchAll && !$hit) { $matched = false; break; } - if (!$matchAll && $hit) { $matched = true; break; } - } - - if (!$matched) continue; - - $result['matched_rules'][] = $rule['name']; - $actStmt = $pdo->prepare('SELECT * FROM ticket_rule_actions WHERE rule_id = ?'); - $actStmt->execute([$rule['id']]); - foreach ($actStmt->fetchAll() as $act) { - switch ($act['action_type']) { - case 'reject': $result['reject'] = true; break; - case 'set_department': $result['set_department'] = (int) $act['action_value']; break; - case 'set_priority': $result['set_priority'] = (int) $act['action_value']; break; - case 'set_help_topic': $result['set_help_topic'] = (int) $act['action_value']; break; - case 'assign_staff': $result['assign_staff'] = (int) $act['action_value']; break; - case 'add_tag': $result['add_tags'][] = $act['action_value']; break; - case 'auto_reply': $result['auto_reply'] = (int) $act['action_value']; break; - } - } - - if ($result['reject']) break; // weitere Regeln sind irrelevant, Ticket wird ohnehin verworfen - } - - return $result; -} - -/** - * Verschickt automatisch einen hinterlegten Textbaustein als Antwort, wenn eine - * Ticket-Regel eine "Auto-Antwort senden"-Aktion ausgelöst hat (siehe - * evaluate_ticket_rules(), Ergebnis-Schlüssel 'auto_reply' = Textbaustein-ID). - * Direkt nach der Ticket-Erstellung aufrufen – NUR an Stellen, an denen das Ticket - * automatisiert (nicht durch einen Mitarbeiter) entsteht: ticket_new.php, - * live_chat.php, cron/fetch_emails.php. Bei manuell von Mitarbeitern angelegten - * Tickets (staff/ticket_create.php) bewusst NICHT aufrufen, da dort schon ein - * Mensch draufgeschaut hat. - */ -function apply_auto_reply(PDO $pdo, int $ticketId, ?int $cannedResponseId): void -{ - if (!$cannedResponseId) return; - - $cannedStmt = $pdo->prepare('SELECT body FROM canned_responses WHERE id = ?'); - $cannedStmt->execute([$cannedResponseId]); - $body = $cannedStmt->fetchColumn(); - if ($body === false) return; // Textbaustein wurde inzwischen gelöscht - - $ticketStmt = $pdo->prepare( - 'SELECT t.*, d.name AS department_name, p.name AS priority_name - FROM tickets t - JOIN departments d ON d.id = t.department_id - JOIN priorities p ON p.id = t.priority_id - WHERE t.id = ?' - ); - $ticketStmt->execute([$ticketId]); - $ticket = $ticketStmt->fetch(); - if (!$ticket) return; - - // author_type='staff' mit staff_id=NULL statt 'system': so rendert die Antwort - // überall (Kundenansicht, Live-Chat-Widget, Mobile App) korrekt als Support-Bubble - // statt versehentlich wie eine Kunden-Nachricht auszusehen – der bestehende Code - // fragt an vielen Stellen nur author_type==='staff' ab, nicht 'system'. - $message = resolve_canned_vars($body, $ticket, []); - $now = db_now(); - - $pdo->prepare( - "INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,'staff',NULL,?,0,?)" - )->execute([$ticketId, $message, $now]); - - $updateFirstResponse = empty($ticket['first_responded_at']); - $sql = "UPDATE tickets SET status = 'answered', updated_at = ?" . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ' WHERE id = ?'; - $params = [$now]; - if ($updateFirstResponse) $params[] = $now; - $params[] = $ticketId; - $pdo->prepare($sql)->execute($params); - - log_activity($pdo, $ticketId, null, 'Automatische Antwort gesendet (Ticket-Regel)'); - - try { - notify_customer_new_reply($ticket, $message); - } catch (Throwable $e) { - error_log('apply_auto_reply: ' . $e->getMessage()); - } -} - -/** Verknüpft (und legt bei Bedarf an) einen Tag mit einem Ticket – gleiche Logik wie staff/ticket_view.php */ -function apply_tag_to_ticket(PDO $pdo, int $ticketId, string $tagName): void -{ - $tagName = trim($tagName); - if ($tagName === '') return; - $stmt = $pdo->prepare('SELECT id FROM tags WHERE name = ?'); - $stmt->execute([$tagName]); - $tagId = $stmt->fetchColumn(); - if (!$tagId) { - $palette = ['#0f6f6a', '#2563eb', '#d97706', '#a855f7', '#16a34a', '#dc2626']; - $color = $palette[array_rand($palette)]; - $pdo->prepare('INSERT INTO tags (name, color) VALUES (?,?)')->execute([$tagName, $color]); - $tagId = (int) $pdo->lastInsertId(); - } - $linkStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_tags WHERE ticket_id = ? AND tag_id = ?'); - $linkStmt->execute([$ticketId, $tagId]); - if ((int) $linkStmt->fetchColumn() === 0) { - $pdo->prepare('INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)')->execute([$ticketId, $tagId]); - } -} - -/** Anzeigenamen der 16 deutschen Bundesländer, Kürzel → Name (für das Feiertags-Dropdown - * in staff/business_hours.php und als Whitelist gültiger Codes für german_public_holidays()). */ -const GERMAN_STATE_NAMES = [ - 'BW' => 'Baden-Württemberg', - 'BY' => 'Bayern', - 'BE' => 'Berlin', - 'BB' => 'Brandenburg', - 'HB' => 'Bremen', - 'HH' => 'Hamburg', - 'HE' => 'Hessen', - 'MV' => 'Mecklenburg-Vorpommern', - 'NI' => 'Niedersachsen', - 'NW' => 'Nordrhein-Westfalen', - 'RP' => 'Rheinland-Pfalz', - 'SL' => 'Saarland', - 'SN' => 'Sachsen', - 'ST' => 'Sachsen-Anhalt', - 'SH' => 'Schleswig-Holstein', - 'TH' => 'Thüringen', -]; - -/** - * Liefert den Buß- und Bettag (nur Sachsen) für ein Jahr: der Mittwoch vor dem 23. - * November – ausgehend vom 22.11. rückwärts zum nächsten Mittwoch gesucht, damit auch der - * Sonderfall "23.11. ist selbst ein Mittwoch" korrekt eine Woche früher landet (z.B. 2022: - * 23.11. war ein Mittwoch → Buß- und Bettag war der 16.11., nicht der 23.11. selbst). - */ -function german_buss_und_bettag(int $year): string -{ - $d = new DateTime("{$year}-11-22"); - while ((int) $d->format('N') !== 3) { // 3 = Mittwoch (ISO-8601: 1=Montag..7=Sonntag) - $d->modify('-1 day'); - } - return $d->format('Y-m-d'); -} - -/** - * Berechnet den Termin des Ostersonntags (gregorianischer Kalender) nach dem - * Gauß/Meeus-Algorithmus – bewusst NICHT PHP's eingebaute easter_date(): die liefert in - * diesem Environment nachweislich ein Datum zu früh (geprüft gegen mehrere bekannte - * Ostertermine, z.B. 2023: PHP gibt den 8.4. statt korrekt 9.4. zurück – konsequent einen - * Tag daneben, über alle getesteten Jahre). Diese Eigenimplementierung ist eine - * eigenständige, gut dokumentierte Standardformel ohne Abhängigkeit von der - * PHP-Calendar-Extension oder deren Plattform-Eigenheiten. - */ -function german_easter_sunday(int $year): DateTime -{ - $a = $year % 19; - $b = intdiv($year, 100); - $c = $year % 100; - $d = intdiv($b, 4); - $e = $b % 4; - $f = intdiv($b + 8, 25); - $g = intdiv($b - $f + 1, 3); - $h = (19 * $a + $b - $d - $g + 15) % 30; - $i = intdiv($c, 4); - $k = $c % 4; - $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; - $m = intdiv($a + 11 * $h + 22 * $l, 451); - $month = intdiv($h + $l - 7 * $m + 114, 31); - $day = (($h + $l - 7 * $m + 114) % 31) + 1; - return new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day)); -} - -/** - * Berechnet die gesetzlichen Feiertage eines deutschen Bundeslandes für ein Kalenderjahr – - * inkl. beweglicher, von Ostern abhängiger Feiertage (siehe german_easter_sunday() oben). - * Wird für den "Feiertage automatisch übernehmen"-Import in staff/business_hours.php - * genutzt, damit nicht jeder Feiertag einzeln von Hand eingetragen werden muss – einfach - * Bundesland + Jahr wählen. - * - * Quelle der Regeln: bundesweit einheitliche Feiertage + je Bundesland zusätzliche, nach - * aktuellem Stand der Feiertagsgesetze der Länder. Mariä Himmelfahrt (Bayern) gilt offiziell - * nur in Gemeinden mit überwiegend katholischer Bevölkerung – wird hier trotzdem als - * Bayern-Feiertag mitgeführt (üblicher Standard in Feiertagslisten), im Zweifel manuell - * wieder entfernen, falls der Standort davon nicht betroffen ist. - * - * @return array sortiert nach Datum - */ -function german_public_holidays(string $stateCode, int $year): array -{ - if (!isset(GERMAN_STATE_NAMES[$stateCode])) { - throw new InvalidArgumentException("Unbekanntes Bundesland-Kürzel: {$stateCode}"); - } - - $easter = german_easter_sunday($year); - $offset = function (int $days) use ($easter): string { - $d = clone $easter; - $d->modify("{$days} days"); - return $d->format('Y-m-d'); - }; - $fixed = fn (string $monthDay): string => "{$year}-{$monthDay}"; - - // Bundesweit einheitliche Feiertage - $holidays = [ - $fixed('01-01') => 'Neujahr', - $offset(-2) => 'Karfreitag', - $offset(1) => 'Ostermontag', - $fixed('05-01') => 'Tag der Arbeit', - $offset(39) => 'Christi Himmelfahrt', - $offset(50) => 'Pfingstmontag', - $fixed('10-03') => 'Tag der Deutschen Einheit', - $fixed('12-25') => '1. Weihnachtstag', - $fixed('12-26') => '2. Weihnachtstag', - ]; - - $dreiKoenige = [$fixed('01-06') => 'Heilige Drei Könige']; - $fronleichnam = [$offset(60) => 'Fronleichnam']; - $mariaHimmelfahrt = [$fixed('08-15') => 'Mariä Himmelfahrt']; - $allerheiligen = [$fixed('11-01') => 'Allerheiligen']; - $reformationstag = [$fixed('10-31') => 'Reformationstag']; - $frauentag = [$fixed('03-08') => 'Internationaler Frauentag']; - $weltkindertag = [$fixed('09-20') => 'Weltkindertag']; - $bussUndBettag = [german_buss_und_bettag($year) => 'Buß- und Bettag']; - - $byState = [ - 'BW' => $dreiKoenige + $fronleichnam + $allerheiligen, - 'BY' => $dreiKoenige + $fronleichnam + $mariaHimmelfahrt + $allerheiligen, - 'BE' => $frauentag, - 'BB' => $reformationstag, - 'HB' => $reformationstag, - 'HH' => $reformationstag, - 'HE' => $fronleichnam, - 'MV' => $reformationstag + $weltkindertag, - 'NI' => $reformationstag, - 'NW' => $fronleichnam + $allerheiligen, - 'RP' => $fronleichnam + $allerheiligen, - 'SL' => $fronleichnam + $mariaHimmelfahrt + $allerheiligen, - 'SN' => $reformationstag + $bussUndBettag, - 'ST' => $dreiKoenige + $reformationstag, - 'SH' => $reformationstag, - 'TH' => $weltkindertag + $reformationstag, - ]; - - $holidays = array_merge($holidays, $byState[$stateCode] ?? []); - ksort($holidays); - - $result = []; - foreach ($holidays as $date => $name) { - $result[] = ['date' => $date, 'name' => $name]; - } - return $result; -} - -/** - * Addiert eine Anzahl Arbeitsstunden zu einem Zeitpunkt, unter Berücksichtigung der - * konfigurierten Geschäftszeiten (Tabelle business_hours) und Feiertage (business_holidays). - * Liegt der Startzeitpunkt außerhalb der Geschäftszeiten, zählt die Frist erst ab dem - * nächsten Öffnungszeitpunkt. - */ -function add_business_hours(string $from, int $hours): string -{ - $pdo = get_db(); - $rows = $pdo->query('SELECT weekday, is_open, open_time, close_time FROM business_hours')->fetchAll(); - $byWeekday = []; - foreach ($rows as $r) $byWeekday[(int) $r['weekday']] = $r; - $holidays = $pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN); - $holidaySet = array_flip($holidays); - - $remainingMinutes = $hours * 60; - $cursor = new DateTime($from); - $maxIterations = 3650; // Sicherheitsgrenze (~10 Jahre Tage), falls z.B. alle Tage geschlossen sind - $iterations = 0; - - while ($remainingMinutes > 0 && $iterations < $maxIterations) { - $iterations++; - $weekday = (int) $cursor->format('w'); - $dateStr = $cursor->format('Y-m-d'); - $dayConfig = $byWeekday[$weekday] ?? null; - - if (!$dayConfig || !$dayConfig['is_open'] || isset($holidaySet[$dateStr])) { - $cursor->modify('+1 day'); - $cursor->setTime(0, 0, 0); - continue; - } - - $dayOpen = new DateTime($dateStr . ' ' . $dayConfig['open_time']); - $dayClose = new DateTime($dateStr . ' ' . $dayConfig['close_time']); - - if ($cursor < $dayOpen) $cursor = clone $dayOpen; - if ($cursor >= $dayClose) { - $cursor->modify('+1 day'); - $cursor->setTime(0, 0, 0); - continue; - } - - // WICHTIG (echter Bug, live auf Produktion nachgewiesen): ganzzahlig per intdiv() - // statt "/ 60" – eine Fließkommazahl mit Nachkommastellen (z.B. 0.3333…) führt bei - // DateTime::modify("+X minutes") zu einem komplett falschen Sprung um tausende - // Jahre statt der paar Sekunden Rundungsdifferenz, die hier eigentlich gemeint war - // (bekannter PHP-DateTime-Fallstrick mit Dezimalwerten in relativen Formaten). - $availableMinutesToday = intdiv($dayClose->getTimestamp() - $cursor->getTimestamp(), 60); - if ($remainingMinutes <= $availableMinutesToday) { - $cursor->modify("+{$remainingMinutes} minutes"); - $remainingMinutes = 0; - } else { - $remainingMinutes -= $availableMinutesToday; - $cursor = clone $dayClose; - $cursor->modify('+1 day'); - $cursor->setTime(0, 0, 0); - } - } - - return $cursor->format('Y-m-d H:i:s'); -} - -// ==================================================================== -// Rollen & Berechtigungen -// ==================================================================== - -/** - * Standard-Berechtigungen je Admin/Agent-Rolle. - * Kann durch eine benutzerdefinierte Rolle (roles.permissions) überschrieben werden. - */ -function default_permissions(string $role): array -{ - if ($role === 'admin') { - return array_fill_keys(array_merge([ - 'reply_ticket','close_ticket','delete_ticket','transfer_ticket', - 'post_note','assign_ticket','manage_canned','view_reports', - 'manage_kb','create_ticket', - ], array_keys(admin_section_permissions())), true); - } - // Standard-Agent - return array_merge([ - 'reply_ticket' => true, - 'close_ticket' => true, - 'delete_ticket' => false, - 'transfer_ticket' => true, - 'post_note' => true, - 'assign_ticket' => true, - 'manage_canned' => false, - 'view_reports' => true, - 'manage_kb' => false, - 'create_ticket' => true, - ], array_fill_keys(array_keys(admin_section_permissions()), false)); -} - -/** - * Feinkörnige Rechte für einzelne Adminportal-Bereiche – damit ein Admin einem Agenten - * (per benutzerdefinierter Rolle, siehe staff/roles.php) gezielt einzelne Admin-Funktionen - * freigeben kann, ohne ihm die volle Administrator-Rolle zu geben ("Co-Admin"). - * Admins haben über has_permission() ohnehin immer automatisch alle Rechte. - */ -function admin_section_permissions(): array -{ - return [ - 'manage_departments' => 'Abteilungen verwalten', - 'manage_priorities' => 'Prioritäten / SLA verwalten', - 'manage_help_topics' => 'Hilfe-Themen verwalten', - 'manage_kb' => 'Wissensdatenbank verwalten', - 'manage_email_addresses' => 'E-Mail-Adressen verwalten', - 'manage_email_templates' => 'E-Mail-Vorlagen verwalten', - 'manage_organizations' => 'Organisationen verwalten', - 'manage_teams' => 'Teams verwalten', - 'manage_roles' => 'Rollen verwalten', - 'manage_staff' => 'Mitarbeiter verwalten', - 'view_audit_log' => 'Admin-Protokoll einsehen', - 'manage_escalations' => 'Eskalationsregeln verwalten', - 'view_time_entries' => 'Zeiterfassung einsehen', - 'manage_business_hours' => 'Geschäftszeiten verwalten', - ]; -} - -/** - * Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat. - * Admins haben immer alle Rechte. Bei Agenten greift ggf. eine benutzerdefinierte Rolle. - */ -function has_permission(array $staff, string $permission, ?int $departmentId = null): bool -{ - if ($staff['role'] === 'admin') return true; - - $roleId = !empty($staff['role_id']) ? (int) $staff['role_id'] : 0; - // Rolle pro Abteilung (erweiterter Zugriff): überschreibt die Hauptrolle nur für diese Abteilung. - // Ohne eigene Rolle an der Zugriffszeile gilt weiter die Hauptrolle. - if ($departmentId !== null && $departmentId !== (int) ($staff['department_id'] ?? 0)) { - static $deptRoleCache = []; - $key = $staff['id'] . ':' . $departmentId; - if (!array_key_exists($key, $deptRoleCache)) { - $stmt = get_db()->prepare('SELECT role_id FROM staff_department WHERE staff_id = ? AND department_id = ?'); - $stmt->execute([$staff['id'], $departmentId]); - $deptRoleCache[$key] = $stmt->fetchColumn() ?: null; - } - if ($deptRoleCache[$key] !== null) $roleId = (int) $deptRoleCache[$key]; - } - - if ($roleId > 0) { - static $roleCache = []; - $rid = $roleId; - if (!array_key_exists($rid, $roleCache)) { - $stmt = get_db()->prepare('SELECT permissions, is_active FROM roles WHERE id = ?'); - $stmt->execute([$rid]); - $row = $stmt->fetch(); - // Deaktivierte oder gelöschte Rolle: es gelten wieder die Standard-Mitarbeiterrechte - $roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null; - } - $perms = $roleCache[$rid]; - if ($perms !== null) return (bool) ($perms[$permission] ?? false); - } - - $defaults = default_permissions('agent'); - return (bool) ($defaults[$permission] ?? false); -} - -// ── @Erwähnungen ──────────────────────────────────────────────── - -/** Parst @Name-Erwähnungen aus einer Nachricht und gibt Staff-IDs zurück */ -function parse_mentions(PDO $pdo, string $message): array -{ - preg_match_all('/@([^\s<,;]+)/u', strip_tags($message), $matches); - if (empty($matches[1])) return []; - $found = []; - foreach ($matches[1] as $name) { - $stmt = $pdo->prepare("SELECT id FROM staff WHERE is_active=1 AND (name=? OR name LIKE ?) LIMIT 1"); - $stmt->execute([$name, $name . ' %']); - $id = $stmt->fetchColumn(); - if ($id) $found[(int)$id] = true; - } - return array_keys($found); -} - -/** Speichert Erwähnungen und benachrichtigt betroffene Mitarbeiter */ -function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, string $message, array $ticket): void -{ - $ids = parse_mentions($pdo, $message); - foreach ($ids as $mentionedId) { - if ($mentionedId === $byStaffId) continue; - $pdo->prepare('INSERT INTO staff_mentions (ticket_id,thread_id,mentioned_staff_id,mentioned_by_staff_id,created_at) VALUES (?,?,?,?,?)') - ->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]); - // E-Mail an erwähnten Mitarbeiter - $stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?'); - $stmt->execute([$mentionedId]); - $mentioned = $stmt->fetch(); - if ($mentioned) { - $by = $pdo->prepare('SELECT name FROM staff WHERE id=?'); - $by->execute([$byStaffId]); - $byName = $by->fetchColumn() ?: 'Ein Mitarbeiter'; - $link = rtrim(config()['site']['url'], '/') . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']); - $inner = email_p("Hallo " . e($mentioned['name']) . ",") - . email_p("" . e($byName) . " hat Sie in Ticket #{$ticket['ticket_number']}: " . e($ticket['subject']) . " erwähnt.", '24px') - . email_button('Zum Ticket', e($link)); - email_send_and_log( - $mentioned['email'], - "Sie wurden in Ticket #{$ticket['ticket_number']} erwähnt", - email_wrap($inner), - null, - 'Erwähnung-Mail', - $ticket['ticket_number'] - ); - } - } -} - -// ── Zeiterfassung ──────────────────────────────────────────────── - -/** Gibt die Summe der erfassten Minuten für ein Ticket zurück */ -function ticket_total_minutes(PDO $pdo, int $ticketId): int -{ - $stmt = $pdo->prepare('SELECT COALESCE(SUM(minutes),0) FROM ticket_time_entries WHERE ticket_id=?'); - $stmt->execute([$ticketId]); - return (int) $stmt->fetchColumn(); -} +/** Schreibt einen Eintrag ins Admin-Aktionsprotokoll (Einstellungen, Stammdaten – getrennt vom Ticket-Aktivitätsprotokoll) */ +function audit_log(?int $staffId, string $action): void +{ + // Spalte ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein zu langer + // Wert sonst eine harte Exception statt einfach zu kürzen. + get_db()->prepare('INSERT INTO admin_audit_log (staff_id, action, created_at) VALUES (?,?,?)') + ->execute([$staffId, mb_substr($action, 0, 255), db_now()]); +} + +/** + * Einfache Rate-Begrenzung: prüft, ob unter $bucket/$identifier in den letzten + * $windowMinutes Minuten bereits $maxAttempts Einträge protokolliert wurden. + * Bei Erlaubnis wird der Versuch sofort selbst protokolliert (Aufruf = Verbrauch). + */ +function rate_limit_check(string $bucket, string $identifier, int $maxAttempts, int $windowMinutes): bool +{ + // Alte Einträge regelmäßig löschen (ca. 1% der Aufrufe) + if (random_int(1, 100) === 1) { + get_db()->prepare('DELETE FROM rate_limit_log WHERE created_at < ?') + ->execute([date('Y-m-d H:i:s', time() - 86400)]); + } + + $pdo = get_db(); + $since = date('Y-m-d H:i:s', time() - $windowMinutes * 60); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM rate_limit_log WHERE bucket = ? AND identifier = ? AND created_at >= ?'); + $stmt->execute([$bucket, $identifier, $since]); + $count = (int) $stmt->fetchColumn(); + if ($count >= $maxAttempts) { + return false; + } + $pdo->prepare('INSERT INTO rate_limit_log (bucket, identifier, created_at) VALUES (?,?,?)') + ->execute([$bucket, $identifier, db_now()]); + return true; +} + +/** Erzeugt ein Passwort-Reset-Token, speichert nur dessen Hash, gibt das Klartext-Token zurück (für den Link) */ +function create_password_reset(string $accountType, string $email): string +{ + $pdo = get_db(); + // 48 Bytes = 96 hex chars – ausreichend Entropie gegen Brute-Force + $token = bin2hex(random_bytes(48)); + // Alte, ungenutzte Tokens für diese E-Mail ungültig machen + $pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND email = ? AND used_at IS NULL') + ->execute([db_now(), $accountType, $email]); + $pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)') + ->execute([$accountType, $email, hash('sha256', $token), date('Y-m-d H:i:s', time() + 3600), db_now()]); + return $token; +} + +/** + * Prüft ob ein Passwort die Mindestanforderungen erfüllt. + * Gibt null zurück wenn OK, sonst eine Fehlermeldung. + */ +function validate_password_strength(string $password): ?string +{ + if (strlen($password) < 10) { + return 'Das Passwort muss mindestens 10 Zeichen lang sein.'; + } + if (!preg_match('/[A-Z]/', $password)) { + return 'Das Passwort muss mindestens einen Großbuchstaben enthalten.'; + } + if (!preg_match('/[a-z]/', $password)) { + return 'Das Passwort muss mindestens einen Kleinbuchstaben enthalten.'; + } + if (!preg_match('/[0-9]/', $password)) { + return 'Das Passwort muss mindestens eine Ziffer enthalten.'; + } + if (!preg_match('/[^A-Za-z0-9]/', $password)) { + return 'Das Passwort muss mindestens ein Sonderzeichen enthalten (!@#$%&* usw.).'; + } + // Triviale Passwörter blockieren + $trivial = ['password','passwort','12345678','123456789','1234567890','qwertzui','admin123','welcome1']; + if (in_array(strtolower($password), $trivial, true)) { + return 'Dieses Passwort ist zu häufig verwendet. Bitte wählen Sie ein sichereres Passwort.'; + } + return null; +} + +/** + * Verschlüsselt einen sensiblen Wert (z.B. SMTP-Passwort) mit AES-256-GCM. + * Schlüssel kommt aus config app_key oder wird aus DB-Credentials abgeleitet. + */ +function encrypt_secret(string $plaintext): string +{ + $key = _get_app_key(); + $iv = random_bytes(12); + $tag = ''; + $encrypted = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); + return base64_encode($iv . $tag . $encrypted); +} + +/** Entschlüsselt einen mit encrypt_secret() verschlüsselten Wert. */ +function decrypt_secret(string $ciphertext): string +{ + if ($ciphertext === '') return ''; + // Legacy: wenn nicht base64-verschlüsselt, Klartext zurückgeben (Migration) + $raw = base64_decode($ciphertext, true); + if ($raw === false || strlen($raw) < 28) return $ciphertext; + $key = _get_app_key(); + $iv = substr($raw, 0, 12); + $tag = substr($raw, 12, 16); + $encrypted = substr($raw, 28); + $decrypted = openssl_decrypt($encrypted, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); + return $decrypted !== false ? $decrypted : $ciphertext; // Fallback auf Klartext +} + +/** Interner Schlüssel für Verschlüsselung – 32 Bytes. */ +/** + * Interner Schlüssel für AES-256-GCM-Verschlüsselung – 32 Bytes. + * + * Priorität: + * 1. settings-Tabelle (key = 'app_key') → überlebt Updates ohne config.php-Änderung + * 2. config.php → app_key → gesetzt vom Installer oder manuell + * 3. Fallback aus DB-Credentials → schwach, aber nie null (Warnung wird angezeigt) + */ +function _get_app_key(): string +{ + // 1. DB-Setting hat höchste Priorität (überlebt Updates) + try { + $stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); + $stmt->execute(['app_key']); + $dbKey = $stmt->fetchColumn(); + if ($dbKey !== false && $dbKey !== '') { + return substr(hash('sha256', $dbKey, true), 0, 32); + } + } catch (\Throwable $e) { + // DB noch nicht verfügbar (z.B. während Installation) → weiter zum Fallback + } + + // 2. config.php + $cfg = config(); + if (!empty($cfg['app_key'])) { + return substr(hash('sha256', $cfg['app_key'], true), 0, 32); + } + + // 3. Schwacher Fallback (Warnung wird im Admin-Header angezeigt) + $seed = ($cfg['db']['pass'] ?? '') . ($cfg['db']['name'] ?? '') . ($cfg['db']['host'] ?? 'localhost'); + return substr(hash('sha256', $seed . 'deskhub-secret', true), 0, 32); +} + +/** Setzt einen Rate-Limit-Zähler zurück (z.B. nach erfolgreichem Login) */ +function rate_limit_reset(string $action, string $key): void +{ + try { + get_db()->prepare('DELETE FROM rate_limit_log WHERE bucket = ? AND identifier = ?') + ->execute([$action, $key]); + } catch (Throwable $e) { /* ignorieren */ } +} + +/** Prüft ein Passwort-Reset-Token und gibt die zugehörige E-Mail-Adresse zurück, oder null wenn ungültig/abgelaufen/verbraucht */ +function verify_password_reset(string $accountType, string $token): ?string +{ + $pdo = get_db(); + $stmt = $pdo->prepare( + "SELECT * FROM password_resets WHERE account_type = ? AND token_hash = ? AND used_at IS NULL AND expires_at >= ? + ORDER BY id DESC LIMIT 1" + ); + $stmt->execute([$accountType, hash('sha256', $token), db_now()]); + $row = $stmt->fetch(); + return $row ? $row['email'] : null; +} + +/** Markiert ein Reset-Token als verbraucht (nach erfolgreichem Zurücksetzen) */ +function consume_password_reset(string $accountType, string $token): void +{ + $pdo = get_db(); + $pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND token_hash = ?') + ->execute([db_now(), $accountType, hash('sha256', $token)]); +} + +/** + * Wandelt eine kleine, sichere Teilmenge von Markdown in HTML um (für Ticket-Antworten). + * Escaped IMMER zuerst den kompletten Text, wendet danach nur feste Muster an – + * dadurch ist eingegebenes HTML niemals aktiv, unabhängig vom Absender. + */ +function markdown_to_html(string $text): string +{ + $text = trim($text); + if ($text === '') return ''; + + $escaped = e($text); + + // Code-Abschnitte `code` + $escaped = preg_replace('/`([^`\n]+)`/', '$1', $escaped); + // Fett **text** + $escaped = preg_replace('/\*\*([^\*\n]+)\*\*/', '$1', $escaped); + // Kursiv *text* + $escaped = preg_replace('/\*([^\*\n]+)\*/', '$1', $escaped); + // Links [text](https://...) - nur http(s) erlaubt + $escaped = preg_replace_callback('/\[([^\]\n]+)\]\((https?:\/\/[^\s\)]+)\)/', function ($m) { + return '' . $m[1] . ''; + }, $escaped); + + // Zeilen in Absätze/Listen umwandeln + $lines = preg_split('/\r?\n/', $escaped); + $html = ''; + $inList = false; + foreach ($lines as $line) { + if (preg_match('/^-\s+(.*)/', $line, $m)) { + if (!$inList) { $html .= '
    '; $inList = true; } + $html .= '
  • ' . $m[1] . '
  • '; + } else { + if ($inList) { $html .= '
'; $inList = false; } + $html .= trim($line) === '' ? '
' : '

' . $line . '

'; + } + } + if ($inList) $html .= ''; + return $html; +} + +/** Schreibt einen Eintrag ins Aktivitätsprotokoll eines Tickets */ +function log_activity(PDO $pdo, int $ticketId, ?int $staffId, string $action): void +{ + $stmt = $pdo->prepare('INSERT INTO activity_log (ticket_id, staff_id, action, created_at) VALUES (?,?,?,?)'); + $stmt->execute([$ticketId, $staffId, $action, db_now()]); +} + +/** + * Sperre für Live-Chat-Tickets (source='chat'): verhindert, dass zwei Mitarbeiter + * unbemerkt gleichzeitig im selben Chat antworten – egal ob über das Web-Backend + * (staff/live_chat.php) oder die Mobile-App (api/staff/v1/ticket_message.php), beide + * teilen sich diese eine Logik, damit sie nicht auseinanderlaufen. Wer den Chat öffnet + * bzw. beantwortet, "besitzt" ihn für ein paar Minuten; danach läuft die Sperre + * automatisch ab (kein Hängenbleiben, wenn z.B. die App einfach geschlossen wird). + * Andere Mitarbeiter dürfen den Chat trotzdem weiter LESEN – nur das Schreiben wird + * ihnen verwehrt, siehe Aufrufer. + */ +const LIVE_CHAT_LOCK_TIMEOUT_MINUTES = 5; + +/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn + * frei, selbst gesperrt, oder die Sperre abgelaufen ist. */ +function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string +{ + $lockFresh = !empty($ticket['locked_at']) + && (time() - strtotime($ticket['locked_at'])) < LIVE_CHAT_LOCK_TIMEOUT_MINUTES * 60; + if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) { + return null; + } + $stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?'); + $stmt->execute([$ticket['locked_by']]); + return $stmt->fetchColumn() ?: 'ein anderer Mitarbeiter'; +} + +/** Übernimmt/erneuert die Sperre für $staffId (nur aufrufen, wenn nicht bereits + * frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */ +function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void +{ + $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') + ->execute([$staffId, db_now(), $ticketId]); +} + +/** + * Generelle Ticket-Sperre für ALLE Tickets (nicht Live-Chat-spezifisch, siehe oben) – + * verhindert, dass zwei Mitarbeiter unbemerkt gleichzeitig am selben Ticket arbeiten. + * Nutzt dieselben locked_by/locked_at-Spalten, aber eine "harte" Semantik: anders als + * beim Live-Chat wird hier nicht nur das Schreiben, sondern auch das Öffnen selbst + * verweigert (siehe Aufrufer in staff/ticket_view.php und api/staff/v1/ticket.php). + * Für Live-Chat-Tickets (source='chat') gilt weiterhin die separate, mildere + * live_chat_locked_by_other()-Logik – NIEMALS beide auf dasselbe Ticket anwenden, sonst + * würden sich die Timeouts widersprechen. + * (Ursprünglich 10 Minuten, auf Nutzerwunsch verkürzt: wer ein Ticket nur kurz öffnet und + * dann wegnavigiert ohne zu antworten/freizugeben, blockiert es sonst unnötig lange für + * andere. Admins können eine fremde Sperre zusätzlich jederzeit sofort aufheben, siehe + * flash_locked_ticket()/get_locked_ticket_override().) + */ +const TICKET_LOCK_TIMEOUT_MINUTES = 5; + +/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn + * frei, selbst gesperrt, oder die Sperre abgelaufen ist. */ +function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string +{ + $lockFresh = !empty($ticket['locked_at']) + && (time() - strtotime($ticket['locked_at'])) < TICKET_LOCK_TIMEOUT_MINUTES * 60; + if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) { + return null; + } + $stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?'); + $stmt->execute([$ticket['locked_by']]); + return $stmt->fetchColumn() ?: 'einem anderen Mitarbeiter'; +} + +/** Übernimmt/erneuert die generelle Ticket-Sperre (nur aufrufen, wenn nicht bereits + * frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */ +function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void +{ + $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') + ->execute([$staffId, db_now(), $ticketId]); +} + +/** Liest eine Einstellung aus der settings-Tabelle (z.B. Logo-Pfad) */ +/** Geteilter In-Request-Cache für get_setting / set_setting (per Referenz). */ +function &_settings_cache(): array +{ + static $cache = []; + return $cache; +} + +/** Liest eine Einstellung aus der DB (mit In-Request-Cache). */ +function get_setting(string $key, ?string $default = null): ?string +{ + $cache = &_settings_cache(); + if (array_key_exists($key, $cache)) { + return $cache[$key]; + } + $stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); + $stmt->execute([$key]); + $value = $stmt->fetchColumn(); + return $cache[$key] = ($value !== false ? $value : $default); +} + +/** + * Speichert eine Einstellung (atomares Upsert, treiber-unabhängig) + * und aktualisiert sofort den In-Request-Cache. + */ +function set_setting(string $key, ?string $value): void +{ + $pdo = get_db(); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + if ($driver === 'sqlite') { + $pdo->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?,?)') + ->execute([$key, $value]); + } else { + $pdo->prepare( + 'INSERT INTO settings (setting_key, setting_value) VALUES (?,?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)' + )->execute([$key, $value]); + } + + // Cache sofort aktualisieren – nachfolgende get_setting()-Aufrufe im selben + // Request liefern den neuen Wert ohne erneuten DB-Trip. + $cache = &_settings_cache(); + $cache[$key] = $value; +} + +/** Formatiert einen Zeitstempel als relative "vor X Min./Std./Tagen"-Angabe (für Cron-/Postfach-Status-Anzeigen). */ +function cron_last_run_label(?string $dt): string +{ + if (!$dt) return 'Noch nie'; + $mins = (int) round((time() - strtotime($dt)) / 60); + if ($mins < 1) return 'Gerade eben'; + if ($mins < 60) return "vor {$mins} Min."; + $h = (int) round($mins / 60); + if ($h < 48) return "vor {$h} Std."; + return 'vor ' . (int) round($h / 24) . ' Tagen'; +} + +/** + * Sorgt dafür, dass die E-Mail-Adresse einer Abteilung automatisch auch als + * Eintrag in der Routing-Tabelle (email_addresses) existiert, damit eingehende + * Mails an diese Adresse ohne separaten Schritt in der richtigen Abteilung + * landen (siehe staff/departments.php, staff/email_addresses.php). + * Bereits vorhandene Routing-Einträge für DIESELBE Adresse werden nie + * überschrieben – auch nicht, wenn sie auf eine andere Abteilung zeigen. + */ +function sync_department_email_route(PDO $pdo, int $departmentId, string $email): void +{ + $email = trim(strtolower($email)); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return; + + $stmt = $pdo->prepare('SELECT id FROM email_addresses WHERE LOWER(email) = ?'); + $stmt->execute([$email]); + if ($stmt->fetchColumn()) { + return; // Adresse ist bereits geroutet (an diese oder eine andere Abteilung) – nicht anfassen + } + + $pdo->prepare('UPDATE email_addresses SET is_default = 0 WHERE department_id = ?')->execute([$departmentId]); + $pdo->prepare('INSERT INTO email_addresses (email, label, department_id, priority_id, is_default, created_at) VALUES (?,?,?,NULL,1,?)') + ->execute([$email, 'Automatisch (Abteilungs-E-Mail)', $departmentId, db_now()]); +} + +/** + * Abteilungen für Auswahllisten, in denen etwas NEU zugeordnet wird (Weiterleiten, Routing, + * Regeln, Hilfethemen …): nur aktive Abteilungen plus die bereits gewählten ($keepIds), damit + * bestehende Zuordnungen beim Bearbeiten nicht verloren gehen. + */ +function departments_for_select(PDO $pdo, array $keepIds = []): array +{ + $keep = array_values(array_filter(array_map('intval', $keepIds))); + $sql = 'SELECT * FROM departments WHERE is_active = 1'; + if ($keep) $sql .= ' OR id IN (' . implode(',', $keep) . ')'; + return $pdo->query($sql . ' ORDER BY name')->fetchAll(); +} + +/** Liefert die ID einer aktiven Abteilung: $id selbst, sonst die Standardabteilung, sonst irgendeine aktive. */ +function active_department_id(PDO $pdo, ?int $id): ?int +{ + if ($id) { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1'); + $stmt->execute([$id]); + if ((int) $stmt->fetchColumn()) return $id; + } + $fallback = $pdo->query('SELECT id FROM departments WHERE is_active = 1 ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn(); + return $fallback ? (int) $fallback : $id; +} + +/** + * Hilfethemen als flache, hierarchisch sortierte Liste (Eltern vor Kindern). + * Jede Zeile enthält zusätzlich 'path' ("Eltern / Kind") und 'depth'. + * Kunden sehen nur aktive, öffentliche Themen ($publicOnly), Mitarbeiter alle aktiven. + */ +function help_topic_options(PDO $pdo, bool $publicOnly = false, bool $activeOnly = true): array +{ + $rows = $pdo->query( + 'SELECT h.*, COALESCE(d.is_active, 1) AS dept_active FROM help_topics h LEFT JOIN departments d ON d.id = h.department_id ORDER BY h.sort_order, h.name' + )->fetchAll(); + $byParent = []; + $byId = []; + foreach ($rows as $r) { + $byId[(int) $r['id']] = $r; + $pid = !empty($r['parent_id']) ? (int) $r['parent_id'] : 0; + $byParent[$pid][] = $r; + } + $out = []; + $walk = function (int $parent, string $prefix, int $depth, array $seen) use (&$walk, &$out, $byParent, $publicOnly, $activeOnly) { + foreach ($byParent[$parent] ?? [] as $r) { + if (isset($seen[(int) $r['id']])) continue; // Schutz vor Zyklen + $path = $prefix === '' ? $r['name'] : $prefix . ' / ' . $r['name']; + $visible = (!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1)); + if ($visible) { + $r['path'] = $path; + $r['depth'] = $depth; + $out[] = $r; + } + $walk((int) $r['id'], $path, $depth + 1, $seen + [(int) $r['id'] => true]); + } + }; + $walk(0, '', 0, []); + // Themen, deren Elternthema nicht existiert, trotzdem anzeigen + $shown = array_column($out, 'id'); + foreach ($rows as $r) { + if (!empty($r['parent_id']) && !isset($byId[(int) $r['parent_id']]) && !in_array($r['id'], $shown, true)) { + if ((!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1))) { + $r['path'] = $r['name']; $r['depth'] = 0; $out[] = $r; + } + } + } + return $out; +} + +/** Gibt die erlaubten Upload-Dateiendungen zurück (aus DB-Setting oder config-Fallback). */ +function get_allowed_extensions(): array +{ + $setting = get_setting('upload_allowed_extensions', ''); + if ($setting) { + $exts = array_filter(array_map('trim', explode(',', strtolower($setting)))); + if ($exts) return array_values($exts); + } + return config()['uploads']['allowed_extensions']; +} + +/** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */ +function get_max_upload_size(): int +{ + $setting = get_setting('upload_max_size_mb', ''); + if ($setting && (int)$setting > 0) { + return (int)$setting * 1024 * 1024; + } + return config()['uploads']['max_size_bytes']; +} + +function handle_ticket_upload(array $file): array +{ + $cfg = config()['uploads']; + $allowed = get_allowed_extensions(); + $maxSize = get_max_upload_size(); + + if ($file['error'] !== UPLOAD_ERR_OK) { + return ['error' => 'Der Datei-Upload ist fehlgeschlagen.']; + } + if ($file['size'] > $maxSize) { + return ['error' => 'Die Datei ist zu groß (max. ' . round($maxSize / 1024 / 1024) . ' MB).']; + } + // Null-Byte im Dateinamen verhindern (PHP < 8 war anfällig) + if (strpos($file['name'], "\0") !== false) { + return ['error' => 'Ungültiger Dateiname.']; + } + // .htaccess und ähnliche Systemdateien explizit blockieren + $basename = strtolower(basename($file['name'])); + if (in_array($basename, ['.htaccess', '.htpasswd', 'web.config', '.env', 'php.ini'], true)) { + return ['error' => 'Dieser Dateiname ist nicht erlaubt.']; + } + // Endung prüfen (nur letzte Endung – verhindert doppelte wie shell.php.jpg) + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + // Doppelte Endungen blockieren (z.B. evil.php.jpg) + $allParts = explode('.', strtolower($file['name'])); + // Auch wenn ein Admin die Liste der erlaubten Endungen erweitert: Typen, die ein Browser auf unserer Domain + // als Seite/Skript ausführen würde (Uploads sind direkt abrufbar), und ausführbare Dateien bleiben gesperrt. + $dangerousExts = ['php','php3','php4','php5','php7','php8','phtml','phar','asp','aspx','jsp','jspx','sh','py','pl','cgi','exe', + 'html','htm','xhtml','shtml','svg','svgz','xml','xsl','xslt','js','mjs','htc','swf','hta','jar','vbs','bat','cmd','ps1','msi','dll','com','scr','cer','crt']; + foreach ($allParts as $part) { + if (in_array($part, $dangerousExts, true)) { + return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.']; + } + } + if (!in_array($ext, $allowed, true)) { + return ['error' => 'Dieser Dateityp ist nicht erlaubt (' . e($ext) . '). Erlaubt: ' . implode(', ', $allowed)]; + } + // MIME-Typ per finfo prüfen (verhindert verschleierte Dateitypen) + $dangerousMimes = [ + 'text/x-php','application/x-php','application/php','text/php', + 'application/x-httpd-php','application/x-httpd-php-source', + 'text/x-script.php','application/x-sh','text/x-shellscript', + ]; + if (function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + if (in_array($mimeType, $dangerousMimes, true)) { + return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.']; + } + } + // Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz + $storedName = bin2hex(random_bytes(16)) . '.' . $ext; + $uploadsDir = realpath(rtrim($cfg['path'], '/')); + $dest = $uploadsDir . '/' . $storedName; + if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) { + return ['error' => 'Ungültiger Upload-Pfad.']; + } + if (!move_uploaded_file($file['tmp_name'], $dest)) { + return ['error' => 'Die Datei konnte nicht gespeichert werden.']; + } + return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']]; +} + +/** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input). + * Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */ +function handle_ticket_uploads_multi(array $filesInput): array +{ + $results = []; + // $_FILES['attachments'] hat Struktur: ['name'=>[...], 'tmp_name'=>[...], ...] + $count = is_array($filesInput['name']) ? count($filesInput['name']) : 0; + for ($i = 0; $i < $count; $i++) { + if (empty($filesInput['name'][$i])) continue; + $single = [ + 'name' => $filesInput['name'][$i], + 'type' => $filesInput['type'][$i], + 'tmp_name' => $filesInput['tmp_name'][$i], + 'error' => $filesInput['error'][$i], + 'size' => $filesInput['size'][$i], + ]; + $results[] = handle_ticket_upload($single); + } + return $results; +} + +/** Gruppiert ein Aggregat-Ergebnis (Spalten "grp"/"c") als [Schlüssel => Anzahl]; "none" für NULL-Gruppe */ +function fetch_grouped_counts(PDO $pdo, string $sql, array $params): array +{ + $out = []; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + foreach ($stmt->fetchAll() as $row) { + $key = $row['grp'] === null ? 'none' : (string) $row['grp']; + $out[$key] = (int) $row['c']; + } + return $out; +} + +function report_avg_response_hours_expression(string $driver): string +{ + return $driver === 'sqlite' + ? '(julianday(t.first_responded_at) - julianday(t.created_at)) * 24' + : 'TIMESTAMPDIFF(SECOND, t.created_at, t.first_responded_at) / 3600.0'; +} + +/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension */ +/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension. + * $deptIds: null = kein Filter (Admin, alle Abteilungen), Array = nur diese Abteilungen (abteilungsbeschränkte Mitarbeiter). */ +function build_report_table(PDO $pdo, string $dimColumn, string $from, string $to, ?array $deptIds = null): array +{ + $tCol = "t.{$dimColumn}"; + [$deptFilterT, $deptParamsT] = dept_filter_sql($deptIds, 't.department_id'); + [$deptFilterPlain, $deptParamsPlain] = dept_filter_sql($deptIds, 'department_id'); + + $geoeffnet = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); + $geschlossen = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.closed_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); + $zugewiesen = fetch_grouped_counts($pdo, + "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id + WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); + $ueberfaellig = fetch_grouped_counts($pdo, + "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id + WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); + $wiederGeoeffnet = fetch_grouped_counts($pdo, + "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id + WHERE al.action = 'Ticket wieder geöffnet' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); + $geloescht = fetch_grouped_counts($pdo, + "SELECT {$dimColumn} AS grp, COUNT(*) c FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterPlain} GROUP BY {$dimColumn}", [$from, $to, ...$deptParamsPlain]); + + $serviceTimes = []; + $stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.resolved_at FROM tickets t WHERE t.resolved_at BETWEEN ? AND ?{$deptFilterT}"); + $stmt->execute([$from, $to, ...$deptParamsT]); + foreach ($stmt->fetchAll() as $r) { + $key = $r['grp'] === null ? 'none' : (string) $r['grp']; + $serviceTimes[$key][] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600; + } + + $responseTimes = []; + $stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.first_responded_at FROM tickets t WHERE t.first_responded_at BETWEEN ? AND ?{$deptFilterT}"); + $stmt->execute([$from, $to, ...$deptParamsT]); + foreach ($stmt->fetchAll() as $r) { + $key = $r['grp'] === null ? 'none' : (string) $r['grp']; + $responseTimes[$key][] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600; + } + + $allKeys = array_unique(array_merge( + array_keys($geoeffnet), array_keys($geschlossen), array_keys($zugewiesen), + array_keys($ueberfaellig), array_keys($wiederGeoeffnet), array_keys($geloescht), + array_keys($serviceTimes), array_keys($responseTimes) + )); + + $rows = []; + foreach ($allKeys as $key) { + $rows[$key] = [ + 'geoeffnet' => $geoeffnet[$key] ?? 0, + 'zugewiesen' => $zugewiesen[$key] ?? 0, + 'ueberfaellig' => $ueberfaellig[$key] ?? 0, + 'geschlossen' => $geschlossen[$key] ?? 0, + 'wieder_geoeffnet' => $wiederGeoeffnet[$key] ?? 0, + 'geloescht' => $geloescht[$key] ?? 0, + 'servicezeit' => isset($serviceTimes[$key]) ? round(array_sum($serviceTimes[$key]) / count($serviceTimes[$key]), 1) : null, + 'reaktionszeit' => isset($responseTimes[$key]) ? round(array_sum($responseTimes[$key]) / count($responseTimes[$key]), 1) : null, + ]; + } + return $rows; +} + +/** + * Allowlist-Sanitizer für Rich-Text-HTML (Antworten, Wissensdatenbank, Vorlagen) und für HTML-E-Mails + * fremder Absender, die im Mitarbeiterbereich angezeigt werden. Nutzt bewusst keine DOM-Erweiterung + * (siehe README: keine zusätzlichen PHP-Erweiterungen nötig). + * + * Vorgehen: Der Text wird in Tags und Text zerlegt. Jedes Tag wird nur mit festem Namen und festen, + * neu geschriebenen Attributen ausgegeben; alles andere (auch ein nicht geschlossenes "<" im Text) + * wird als Text maskiert. Dadurch kann kein Rest-Markup entstehen, das sich mit nachfolgendem HTML + * der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities), + * weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert. + */ +function sanitize_staff_html(string $html): string +{ + if (trim($html) === '') return ''; + + // Gefährliche Elemente komplett inkl. Inhalt entfernen + $blocked = 'script|style|iframe|frame|frameset|object|embed|applet|link|meta|base|form|input|textarea|select|option|button|svg|math|template|noscript|title|head|xml'; + $html = preg_replace('/<(' . $blocked . ')\b[^>]*>.*?<\/\1\s*>/is', '', $html); + $html = preg_replace('/<\/?(' . $blocked . ')\b[^>]*>/is', '', $html); + // Kommentare, Doctype, bedingte Kommentare und Verarbeitungsanweisungen entfernen + $html = preg_replace('//s', '', $html); + $html = preg_replace('/<[!?][^>]*>/s', '', $html); + + $allowed = [ + 'b','strong','i','em','u','s','ul','ol','li','br','p','div','span','a', + 'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col', + 'h1','h2','h3','h4','h5','h6','hr','blockquote','pre','code','img', + 'figure','figcaption','small','sup','sub', + ]; + $styleTags = ['img','td','th','tr','table','div','span','p','blockquote']; + $extraAttrs = [ + 'img' => ['alt','width','height'], + 'td' => ['colspan','rowspan','align','valign','width'], + 'th' => ['colspan','rowspan','align','valign','width'], + 'table' => ['border','cellpadding','cellspacing','width'], + ]; + + // Attributwert lesen (Anführungszeichen Pflicht, Name nicht Teil eines anderen Namens), Entities auflösen + $attr = static function (string $raw, string $name): ?string { + if (!preg_match('/(?{}]|/\*|expression|javascript|behavior|-moz-binding|url\s*\(|image\s*\(|var\s*\(|attr\s*\(#i', $val)) continue; + $plain = preg_replace('/\b(?:rgb|rgba|hsl|hsla)\([0-9.,%\s\/]*\)/i', '', $val); + if (str_contains((string) $plain, '(') || strlen($val) > 120) continue; + $out[] = $prop . ': ' . $val; + } + return implode('; ', $out); + }; + + $e = static fn(string $v): string => htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $parts = preg_split('/(<[^<>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE); + $result = ''; + foreach ($parts as $i => $part) { + if ($part === '') continue; + if ($i % 2 === 0) { + // Text: nie ein "<" oder ">" durchlassen, das der Browser als Markup lesen könnte + $result .= str_replace(['<', '>'], ['<', '>'], $part); + continue; + } + if (!preg_match('/^<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)(\/?)>$/s', $part, $m)) { + $result .= str_replace(['<', '>'], ['<', '>'], $part); // kein Tag (z. B. "a < b und c > d"): als Text behalten + continue; + } + $tag = strtolower($m[2]); + if (!in_array($tag, $allowed, true)) continue; + if ($m[1] === '/') { $result .= ""; continue; } + $raw = $m[3]; + $out = ''; + + if ($tag === 'a') { + $href = $attr($raw, 'href'); + if ($href !== null && preg_match('#^(https?:|mailto:|cid:)#i', $href)) $out .= ' href="' . $e($href) . '"'; + $result .= ''; + continue; + } + if ($tag === 'img') { + $src = $attr($raw, 'src'); + if ($src !== null) { + // Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen + if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#i', $src)) { + $out .= ' src="' . $e($src) . '"'; + } else { + $out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"'; + } + } + } + foreach ($extraAttrs[$tag] ?? [] as $name) { + $v = $attr($raw, $name); + if ($v !== null) $out .= ' ' . $name . '="' . $e($v) . '"'; + } + if (in_array($tag, $styleTags, true)) { + $st = $attr($raw, 'style'); + if ($st !== null) { + $st = $cleanStyle($st); + if ($st !== '') $out .= ' style="' . $e($st) . '"'; + } + } + $result .= ($m[4] === '/') ? "<{$tag}{$out}/>" : "<{$tag}{$out}>"; + } + + return $result; +} + +/** + * Ersetzt Variablen in einem Canned-Response-Text. + * + * Verfügbare Variablen: + * {{kundename}} Name des Ticketersellers + * {{vorname}} Erster Teil des Namens + * {{email}} E-Mail des Kunden + * {{ticketnummer}} Ticket-Nummer (z.B. #532085) + * {{betreff}} Betreff des Tickets + * {{abteilung}} Name der Abteilung + * {{prioritaet}} Priorität + * {{status}} Aktueller Status + * {{mitarbeiter}} Name des zugewiesenen Mitarbeiters + * {{datum}} Heutiges Datum (TT.MM.JJJJ) + * {{uhrzeit}} Aktuelle Uhrzeit (HH:MM) + * {{signatur}} Signatur des eingeloggten Mitarbeiters + */ +function resolve_canned_vars(string $text, array $ticket, array $staff): string +{ + $name = $ticket['requester_name'] ?? ''; + $vorname = explode(' ', trim($name))[0] ?? $name; + + $vars = [ + '{{kundename}}' => $name, + '{{vorname}}' => $vorname, + '{{email}}' => $ticket['requester_email'] ?? '', + '{{ticketnummer}}' => '#' . ($ticket['ticket_number'] ?? ''), + '{{betreff}}' => $ticket['subject'] ?? '', + '{{abteilung}}' => $ticket['department_name'] ?? '', + '{{prioritaet}}' => $ticket['priority_name'] ?? '', + '{{status}}' => $ticket['status'] ?? '', + '{{mitarbeiter}}' => $ticket['staff_name'] ?? $staff['name'] ?? '', + '{{datum}}' => date('d.m.Y'), + '{{uhrzeit}}' => date('H:i'), + '{{signatur}}' => $staff['signature'] ?? '', + ]; + + return str_replace(array_keys($vars), array_values($vars), $text); +} + +/** + * Prüft ob ein Mitarbeiter aktuell verfügbar ist (nicht im Urlaub, + * Status nicht 'offline', und falls Schichten definiert: innerhalb der Schicht). + */ +function is_staff_available(PDO $pdo, int $staffId): bool +{ + // Urlaub + $s = $pdo->prepare('SELECT is_on_vacation FROM staff WHERE id = ?'); + $s->execute([$staffId]); + $row = $s->fetch(); + if (!$row || $row['is_on_vacation']) return false; + + // Echtzeit-Status + try { + $s = $pdo->prepare('SELECT status FROM staff_availability WHERE staff_id = ?'); + $s->execute([$staffId]); + $avail = $s->fetchColumn(); + if ($avail === 'offline') return false; + } catch (Throwable $e) { /* Tabelle noch nicht angelegt */ } + + // Schichtplan – nur prüfen wenn Schichten definiert sind + try { + $s = $pdo->prepare('SELECT COUNT(*) FROM staff_shifts WHERE staff_id = ? AND is_active = 1'); + $s->execute([$staffId]); + if ((int)$s->fetchColumn() > 0) { + $dow = (int)date('N'); // 1=Mo … 7=So + $time = date('H:i:s'); + $s = $pdo->prepare( + 'SELECT COUNT(*) FROM staff_shifts + WHERE staff_id = ? AND weekday = ? AND is_active = 1 + AND start_time <= ? AND end_time > ?' + ); + $s->execute([$staffId, $dow, $time, $time]); + if ((int)$s->fetchColumn() === 0) return false; // außerhalb Schicht + } + } catch (Throwable $e) { /* ignorieren */ } + + return true; +} + +/** Prüft, ob eine E-Mail-Adresse (per exaktem Treffer oder Domain) auf der Sperrliste steht */ +function is_banned(string $email): bool +{ + $email = trim(strtolower($email)); + if ($email === '') return false; + $domain = substr(strrchr($email, '@'), 1) ?: ''; + + $stmt = get_db()->prepare( + "SELECT 1 FROM banlist WHERE + (match_type = 'email' AND LOWER(value) = ?) + OR (match_type = 'domain' AND LOWER(value) = ?) + LIMIT 1" + ); + $stmt->execute([$email, $domain]); + return (bool) $stmt->fetchColumn(); +} + +/** + * Wertet alle aktiven Ticket-Regeln (sort_order) gegen die Daten eines neuen Tickets aus + * und liefert die anzuwendenden Aktionen zurück. $ticketData braucht die Schlüssel: + * subject, message, requester_email, requester_name, source. + * Sobald eine Regel mit der Aktion "reject" zutrifft, werden keine weiteren Regeln mehr geprüft. + */ +function evaluate_ticket_rules(PDO $pdo, array $ticketData): array +{ + $result = [ + 'reject' => false, + 'set_department' => null, + 'set_priority' => null, + 'set_help_topic' => null, + 'assign_staff' => null, + 'add_tags' => [], + 'auto_reply' => null, + 'close' => false, + 'matched_rules' => [], + ]; + + $rules = $pdo->query('SELECT * FROM ticket_rules WHERE is_active = 1 ORDER BY sort_order, id')->fetchAll(); + + foreach ($rules as $rule) { + $condStmt = $pdo->prepare('SELECT * FROM ticket_rule_conditions WHERE rule_id = ?'); + $condStmt->execute([$rule['id']]); + $conditions = $condStmt->fetchAll(); + if (!$conditions) continue; + + $matchAll = $rule['match_type'] === 'all'; + $matched = $matchAll; // bei "all" optimistisch starten, bei "any" pessimistisch + foreach ($conditions as $cond) { + $fieldValue = mb_strtolower((string) ($ticketData[$cond['field']] ?? '')); + $condValue = mb_strtolower($cond['value']); + $hit = match ($cond['operator']) { + 'contains' => $condValue !== '' && str_contains($fieldValue, $condValue), + 'not_contains' => $condValue === '' || !str_contains($fieldValue, $condValue), + 'equals' => $fieldValue === $condValue, + 'starts_with' => str_starts_with($fieldValue, $condValue), + 'ends_with' => str_ends_with($fieldValue, $condValue), + default => false, + }; + if ($matchAll && !$hit) { $matched = false; break; } + if (!$matchAll && $hit) { $matched = true; break; } + } + + if (!$matched) continue; + + $result['matched_rules'][] = $rule['name']; + $actStmt = $pdo->prepare('SELECT * FROM ticket_rule_actions WHERE rule_id = ?'); + $actStmt->execute([$rule['id']]); + foreach ($actStmt->fetchAll() as $act) { + switch ($act['action_type']) { + case 'reject': $result['reject'] = true; break; + case 'close': $result['close'] = true; break; + case 'set_department': $result['set_department'] = (int) $act['action_value']; break; + case 'set_priority': $result['set_priority'] = (int) $act['action_value']; break; + case 'set_help_topic': $result['set_help_topic'] = (int) $act['action_value']; break; + case 'assign_staff': $result['assign_staff'] = (int) $act['action_value']; break; + case 'add_tag': $result['add_tags'][] = $act['action_value']; break; + case 'auto_reply': $result['auto_reply'] = (int) $act['action_value']; break; + } + } + + if ($result['reject']) break; // weitere Regeln sind irrelevant, Ticket wird ohnehin verworfen + } + + return $result; +} + +/** + * Schließt ein neu angelegtes Ticket, wenn eine Regel die Aktion "Ticket schließen" enthält. Gibt true zurück, wenn geschlossen wurde; + * dann sollten keine Benachrichtigungen an Mitarbeiter oder Kunden mehr verschickt werden. + */ +function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool +{ + if (empty($ruleResult['close'])) return false; + $now = db_now(); + $pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]); + log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)'); + return true; +} + +/** + * Verschickt automatisch einen hinterlegten Textbaustein als Antwort, wenn eine + * Ticket-Regel eine "Auto-Antwort senden"-Aktion ausgelöst hat (siehe + * evaluate_ticket_rules(), Ergebnis-Schlüssel 'auto_reply' = Textbaustein-ID). + * Direkt nach der Ticket-Erstellung aufrufen – NUR an Stellen, an denen das Ticket + * automatisiert (nicht durch einen Mitarbeiter) entsteht: ticket_new.php, + * live_chat.php, cron/fetch_emails.php. Bei manuell von Mitarbeitern angelegten + * Tickets (staff/ticket_create.php) bewusst NICHT aufrufen, da dort schon ein + * Mensch draufgeschaut hat. + */ +function apply_auto_reply(PDO $pdo, int $ticketId, ?int $cannedResponseId): void +{ + if (!$cannedResponseId) return; + + $cannedStmt = $pdo->prepare('SELECT body FROM canned_responses WHERE id = ?'); + $cannedStmt->execute([$cannedResponseId]); + $body = $cannedStmt->fetchColumn(); + if ($body === false) return; // Textbaustein wurde inzwischen gelöscht + + $ticketStmt = $pdo->prepare( + 'SELECT t.*, d.name AS department_name, p.name AS priority_name + FROM tickets t + JOIN departments d ON d.id = t.department_id + JOIN priorities p ON p.id = t.priority_id + WHERE t.id = ?' + ); + $ticketStmt->execute([$ticketId]); + $ticket = $ticketStmt->fetch(); + if (!$ticket) return; + + // author_type='staff' mit staff_id=NULL statt 'system': so rendert die Antwort + // überall (Kundenansicht, Live-Chat-Widget, Mobile App) korrekt als Support-Bubble + // statt versehentlich wie eine Kunden-Nachricht auszusehen – der bestehende Code + // fragt an vielen Stellen nur author_type==='staff' ab, nicht 'system'. + $message = resolve_canned_vars($body, $ticket, []); + $now = db_now(); + + $pdo->prepare( + "INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,'staff',NULL,?,0,?)" + )->execute([$ticketId, $message, $now]); + + $updateFirstResponse = empty($ticket['first_responded_at']); + $sql = "UPDATE tickets SET status = 'answered', updated_at = ?" . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ' WHERE id = ?'; + $params = [$now]; + if ($updateFirstResponse) $params[] = $now; + $params[] = $ticketId; + $pdo->prepare($sql)->execute($params); + + log_activity($pdo, $ticketId, null, 'Automatische Antwort gesendet (Ticket-Regel)'); + + try { + notify_customer_new_reply($ticket, $message); + } catch (Throwable $e) { + error_log('apply_auto_reply: ' . $e->getMessage()); + } +} + +/** Verknüpft (und legt bei Bedarf an) einen Tag mit einem Ticket – gleiche Logik wie staff/ticket_view.php */ +function apply_tag_to_ticket(PDO $pdo, int $ticketId, string $tagName): void +{ + $tagName = trim($tagName); + if ($tagName === '') return; + $stmt = $pdo->prepare('SELECT id FROM tags WHERE name = ?'); + $stmt->execute([$tagName]); + $tagId = $stmt->fetchColumn(); + if (!$tagId) { + $palette = ['#0f6f6a', '#2563eb', '#d97706', '#a855f7', '#16a34a', '#dc2626']; + $color = $palette[array_rand($palette)]; + $pdo->prepare('INSERT INTO tags (name, color) VALUES (?,?)')->execute([$tagName, $color]); + $tagId = (int) $pdo->lastInsertId(); + } + $linkStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_tags WHERE ticket_id = ? AND tag_id = ?'); + $linkStmt->execute([$ticketId, $tagId]); + if ((int) $linkStmt->fetchColumn() === 0) { + $pdo->prepare('INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)')->execute([$ticketId, $tagId]); + } +} + +/** Anzeigenamen der 16 deutschen Bundesländer, Kürzel → Name (für das Feiertags-Dropdown + * in staff/business_hours.php und als Whitelist gültiger Codes für german_public_holidays()). */ +const GERMAN_STATE_NAMES = [ + 'BW' => 'Baden-Württemberg', + 'BY' => 'Bayern', + 'BE' => 'Berlin', + 'BB' => 'Brandenburg', + 'HB' => 'Bremen', + 'HH' => 'Hamburg', + 'HE' => 'Hessen', + 'MV' => 'Mecklenburg-Vorpommern', + 'NI' => 'Niedersachsen', + 'NW' => 'Nordrhein-Westfalen', + 'RP' => 'Rheinland-Pfalz', + 'SL' => 'Saarland', + 'SN' => 'Sachsen', + 'ST' => 'Sachsen-Anhalt', + 'SH' => 'Schleswig-Holstein', + 'TH' => 'Thüringen', +]; + +/** + * Liefert den Buß- und Bettag (nur Sachsen) für ein Jahr: der Mittwoch vor dem 23. + * November – ausgehend vom 22.11. rückwärts zum nächsten Mittwoch gesucht, damit auch der + * Sonderfall "23.11. ist selbst ein Mittwoch" korrekt eine Woche früher landet (z.B. 2022: + * 23.11. war ein Mittwoch → Buß- und Bettag war der 16.11., nicht der 23.11. selbst). + */ +function german_buss_und_bettag(int $year): string +{ + $d = new DateTime("{$year}-11-22"); + while ((int) $d->format('N') !== 3) { // 3 = Mittwoch (ISO-8601: 1=Montag..7=Sonntag) + $d->modify('-1 day'); + } + return $d->format('Y-m-d'); +} + +/** + * Berechnet den Termin des Ostersonntags (gregorianischer Kalender) nach dem + * Gauß/Meeus-Algorithmus – bewusst NICHT PHP's eingebaute easter_date(): die liefert in + * diesem Environment nachweislich ein Datum zu früh (geprüft gegen mehrere bekannte + * Ostertermine, z.B. 2023: PHP gibt den 8.4. statt korrekt 9.4. zurück – konsequent einen + * Tag daneben, über alle getesteten Jahre). Diese Eigenimplementierung ist eine + * eigenständige, gut dokumentierte Standardformel ohne Abhängigkeit von der + * PHP-Calendar-Extension oder deren Plattform-Eigenheiten. + */ +function german_easter_sunday(int $year): DateTime +{ + $a = $year % 19; + $b = intdiv($year, 100); + $c = $year % 100; + $d = intdiv($b, 4); + $e = $b % 4; + $f = intdiv($b + 8, 25); + $g = intdiv($b - $f + 1, 3); + $h = (19 * $a + $b - $d - $g + 15) % 30; + $i = intdiv($c, 4); + $k = $c % 4; + $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; + $m = intdiv($a + 11 * $h + 22 * $l, 451); + $month = intdiv($h + $l - 7 * $m + 114, 31); + $day = (($h + $l - 7 * $m + 114) % 31) + 1; + return new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day)); +} + +/** + * Berechnet die gesetzlichen Feiertage eines deutschen Bundeslandes für ein Kalenderjahr – + * inkl. beweglicher, von Ostern abhängiger Feiertage (siehe german_easter_sunday() oben). + * Wird für den "Feiertage automatisch übernehmen"-Import in staff/business_hours.php + * genutzt, damit nicht jeder Feiertag einzeln von Hand eingetragen werden muss – einfach + * Bundesland + Jahr wählen. + * + * Quelle der Regeln: bundesweit einheitliche Feiertage + je Bundesland zusätzliche, nach + * aktuellem Stand der Feiertagsgesetze der Länder. Mariä Himmelfahrt (Bayern) gilt offiziell + * nur in Gemeinden mit überwiegend katholischer Bevölkerung – wird hier trotzdem als + * Bayern-Feiertag mitgeführt (üblicher Standard in Feiertagslisten), im Zweifel manuell + * wieder entfernen, falls der Standort davon nicht betroffen ist. + * + * @return array sortiert nach Datum + */ +function german_public_holidays(string $stateCode, int $year): array +{ + if (!isset(GERMAN_STATE_NAMES[$stateCode])) { + throw new InvalidArgumentException("Unbekanntes Bundesland-Kürzel: {$stateCode}"); + } + + $easter = german_easter_sunday($year); + $offset = function (int $days) use ($easter): string { + $d = clone $easter; + $d->modify("{$days} days"); + return $d->format('Y-m-d'); + }; + $fixed = fn (string $monthDay): string => "{$year}-{$monthDay}"; + + // Bundesweit einheitliche Feiertage + $holidays = [ + $fixed('01-01') => 'Neujahr', + $offset(-2) => 'Karfreitag', + $offset(1) => 'Ostermontag', + $fixed('05-01') => 'Tag der Arbeit', + $offset(39) => 'Christi Himmelfahrt', + $offset(50) => 'Pfingstmontag', + $fixed('10-03') => 'Tag der Deutschen Einheit', + $fixed('12-25') => '1. Weihnachtstag', + $fixed('12-26') => '2. Weihnachtstag', + ]; + + $dreiKoenige = [$fixed('01-06') => 'Heilige Drei Könige']; + $fronleichnam = [$offset(60) => 'Fronleichnam']; + $mariaHimmelfahrt = [$fixed('08-15') => 'Mariä Himmelfahrt']; + $allerheiligen = [$fixed('11-01') => 'Allerheiligen']; + $reformationstag = [$fixed('10-31') => 'Reformationstag']; + $frauentag = [$fixed('03-08') => 'Internationaler Frauentag']; + $weltkindertag = [$fixed('09-20') => 'Weltkindertag']; + $bussUndBettag = [german_buss_und_bettag($year) => 'Buß- und Bettag']; + + $byState = [ + 'BW' => $dreiKoenige + $fronleichnam + $allerheiligen, + 'BY' => $dreiKoenige + $fronleichnam + $mariaHimmelfahrt + $allerheiligen, + 'BE' => $frauentag, + 'BB' => $reformationstag, + 'HB' => $reformationstag, + 'HH' => $reformationstag, + 'HE' => $fronleichnam, + 'MV' => $reformationstag + $weltkindertag, + 'NI' => $reformationstag, + 'NW' => $fronleichnam + $allerheiligen, + 'RP' => $fronleichnam + $allerheiligen, + 'SL' => $fronleichnam + $mariaHimmelfahrt + $allerheiligen, + 'SN' => $reformationstag + $bussUndBettag, + 'ST' => $dreiKoenige + $reformationstag, + 'SH' => $reformationstag, + 'TH' => $weltkindertag + $reformationstag, + ]; + + $holidays = array_merge($holidays, $byState[$stateCode] ?? []); + ksort($holidays); + + $result = []; + foreach ($holidays as $date => $name) { + $result[] = ['date' => $date, 'name' => $name]; + } + return $result; +} + +/** + * Addiert eine Anzahl Arbeitsstunden zu einem Zeitpunkt, unter Berücksichtigung der + * konfigurierten Geschäftszeiten (Tabelle business_hours) und Feiertage (business_holidays). + * Liegt der Startzeitpunkt außerhalb der Geschäftszeiten, zählt die Frist erst ab dem + * nächsten Öffnungszeitpunkt. + */ +function add_business_hours(string $from, int $hours): string +{ + $pdo = get_db(); + $rows = $pdo->query('SELECT weekday, is_open, open_time, close_time FROM business_hours')->fetchAll(); + $byWeekday = []; + foreach ($rows as $r) $byWeekday[(int) $r['weekday']] = $r; + $holidays = $pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN); + $holidaySet = array_flip($holidays); + + $remainingMinutes = $hours * 60; + $cursor = new DateTime($from); + $maxIterations = 3650; // Sicherheitsgrenze (~10 Jahre Tage), falls z.B. alle Tage geschlossen sind + $iterations = 0; + + while ($remainingMinutes > 0 && $iterations < $maxIterations) { + $iterations++; + $weekday = (int) $cursor->format('w'); + $dateStr = $cursor->format('Y-m-d'); + $dayConfig = $byWeekday[$weekday] ?? null; + + if (!$dayConfig || !$dayConfig['is_open'] || isset($holidaySet[$dateStr])) { + $cursor->modify('+1 day'); + $cursor->setTime(0, 0, 0); + continue; + } + + $dayOpen = new DateTime($dateStr . ' ' . $dayConfig['open_time']); + $dayClose = new DateTime($dateStr . ' ' . $dayConfig['close_time']); + + if ($cursor < $dayOpen) $cursor = clone $dayOpen; + if ($cursor >= $dayClose) { + $cursor->modify('+1 day'); + $cursor->setTime(0, 0, 0); + continue; + } + + // WICHTIG (echter Bug, live auf Produktion nachgewiesen): ganzzahlig per intdiv() + // statt "/ 60" – eine Fließkommazahl mit Nachkommastellen (z.B. 0.3333…) führt bei + // DateTime::modify("+X minutes") zu einem komplett falschen Sprung um tausende + // Jahre statt der paar Sekunden Rundungsdifferenz, die hier eigentlich gemeint war + // (bekannter PHP-DateTime-Fallstrick mit Dezimalwerten in relativen Formaten). + $availableMinutesToday = intdiv($dayClose->getTimestamp() - $cursor->getTimestamp(), 60); + if ($remainingMinutes <= $availableMinutesToday) { + $cursor->modify("+{$remainingMinutes} minutes"); + $remainingMinutes = 0; + } else { + $remainingMinutes -= $availableMinutesToday; + $cursor = clone $dayClose; + $cursor->modify('+1 day'); + $cursor->setTime(0, 0, 0); + } + } + + return $cursor->format('Y-m-d H:i:s'); +} + +// ==================================================================== +// Rollen & Berechtigungen +// ==================================================================== + +/** + * Standard-Berechtigungen je Admin/Agent-Rolle. + * Kann durch eine benutzerdefinierte Rolle (roles.permissions) überschrieben werden. + */ +function default_permissions(string $role): array +{ + if ($role === 'admin') { + return array_fill_keys(array_merge([ + 'reply_ticket','close_ticket','delete_ticket','transfer_ticket', + 'post_note','assign_ticket','manage_canned','view_reports', + 'manage_kb','create_ticket', + ], array_keys(admin_section_permissions())), true); + } + // Standard-Agent + return array_merge([ + 'reply_ticket' => true, + 'close_ticket' => true, + 'delete_ticket' => false, + 'transfer_ticket' => true, + 'post_note' => true, + 'assign_ticket' => true, + 'manage_canned' => false, + 'view_reports' => true, + 'manage_kb' => false, + 'create_ticket' => true, + ], array_fill_keys(array_keys(admin_section_permissions()), false)); +} + +/** + * Feinkörnige Rechte für einzelne Adminportal-Bereiche – damit ein Admin einem Agenten + * (per benutzerdefinierter Rolle, siehe staff/roles.php) gezielt einzelne Admin-Funktionen + * freigeben kann, ohne ihm die volle Administrator-Rolle zu geben ("Co-Admin"). + * Admins haben über has_permission() ohnehin immer automatisch alle Rechte. + */ +function admin_section_permissions(): array +{ + return [ + 'manage_departments' => 'Abteilungen verwalten', + 'manage_priorities' => 'Prioritäten / SLA verwalten', + 'manage_help_topics' => 'Hilfe-Themen verwalten', + 'manage_kb' => 'Wissensdatenbank verwalten', + 'manage_email_addresses' => 'E-Mail-Adressen verwalten', + 'manage_email_templates' => 'E-Mail-Vorlagen verwalten', + 'manage_organizations' => 'Organisationen verwalten', + 'manage_teams' => 'Teams verwalten', + 'manage_roles' => 'Rollen verwalten', + 'manage_staff' => 'Mitarbeiter verwalten', + 'view_audit_log' => 'Admin-Protokoll einsehen', + 'manage_escalations' => 'Eskalationsregeln verwalten', + 'view_time_entries' => 'Zeiterfassung einsehen', + 'manage_business_hours' => 'Geschäftszeiten verwalten', + ]; +} + +/** + * Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat. + * Admins haben immer alle Rechte. Bei Agenten greift ggf. eine benutzerdefinierte Rolle. + */ +function has_permission(array $staff, string $permission, ?int $departmentId = null): bool +{ + if ($staff['role'] === 'admin') return true; + + $roleId = !empty($staff['role_id']) ? (int) $staff['role_id'] : 0; + // Rolle pro Abteilung (erweiterter Zugriff): überschreibt die Hauptrolle nur für diese Abteilung. + // Ohne eigene Rolle an der Zugriffszeile gilt weiter die Hauptrolle. + if ($departmentId !== null && $departmentId !== (int) ($staff['department_id'] ?? 0)) { + static $deptRoleCache = []; + $key = $staff['id'] . ':' . $departmentId; + if (!array_key_exists($key, $deptRoleCache)) { + $stmt = get_db()->prepare('SELECT role_id FROM staff_department WHERE staff_id = ? AND department_id = ?'); + $stmt->execute([$staff['id'], $departmentId]); + $deptRoleCache[$key] = $stmt->fetchColumn() ?: null; + } + if ($deptRoleCache[$key] !== null) $roleId = (int) $deptRoleCache[$key]; + } + + if ($roleId > 0) { + static $roleCache = []; + $rid = $roleId; + if (!array_key_exists($rid, $roleCache)) { + $stmt = get_db()->prepare('SELECT permissions, is_active FROM roles WHERE id = ?'); + $stmt->execute([$rid]); + $row = $stmt->fetch(); + // Deaktivierte oder gelöschte Rolle: es gelten wieder die Standard-Mitarbeiterrechte + $roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null; + } + $perms = $roleCache[$rid]; + if ($perms !== null) return (bool) ($perms[$permission] ?? false); + } + + $defaults = default_permissions('agent'); + return (bool) ($defaults[$permission] ?? false); +} + +// ── @Erwähnungen ──────────────────────────────────────────────── + +/** Parst @Name-Erwähnungen aus einer Nachricht und gibt Staff-IDs zurück */ +function parse_mentions(PDO $pdo, string $message): array +{ + preg_match_all('/@([^\s<,;]+)/u', strip_tags($message), $matches); + if (empty($matches[1])) return []; + $found = []; + foreach ($matches[1] as $name) { + $stmt = $pdo->prepare("SELECT id FROM staff WHERE is_active=1 AND (name=? OR name LIKE ?) LIMIT 1"); + $stmt->execute([$name, $name . ' %']); + $id = $stmt->fetchColumn(); + if ($id) $found[(int)$id] = true; + } + return array_keys($found); +} + +/** Speichert Erwähnungen und benachrichtigt betroffene Mitarbeiter */ +function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, string $message, array $ticket): void +{ + $ids = parse_mentions($pdo, $message); + foreach ($ids as $mentionedId) { + if ($mentionedId === $byStaffId) continue; + $pdo->prepare('INSERT INTO staff_mentions (ticket_id,thread_id,mentioned_staff_id,mentioned_by_staff_id,created_at) VALUES (?,?,?,?,?)') + ->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]); + // E-Mail an erwähnten Mitarbeiter + $stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?'); + $stmt->execute([$mentionedId]); + $mentioned = $stmt->fetch(); + if ($mentioned) { + $by = $pdo->prepare('SELECT name FROM staff WHERE id=?'); + $by->execute([$byStaffId]); + $byName = $by->fetchColumn() ?: 'Ein Mitarbeiter'; + $link = rtrim(config()['site']['url'], '/') . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']); + $inner = email_p("Hallo " . e($mentioned['name']) . ",") + . email_p("" . e($byName) . " hat Sie in Ticket #{$ticket['ticket_number']}: " . e($ticket['subject']) . " erwähnt.", '24px') + . email_button('Zum Ticket', e($link)); + email_send_and_log( + $mentioned['email'], + "Sie wurden in Ticket #{$ticket['ticket_number']} erwähnt", + email_wrap($inner), + null, + 'Erwähnung-Mail', + $ticket['ticket_number'] + ); + } + } +} + +// ── Zeiterfassung ──────────────────────────────────────────────── + +/** Gibt die Summe der erfassten Minuten für ein Ticket zurück */ +function ticket_total_minutes(PDO $pdo, int $ticketId): int +{ + $stmt = $pdo->prepare('SELECT COALESCE(SUM(minutes),0) FROM ticket_time_entries WHERE ticket_id=?'); + $stmt->execute([$ticketId]); + return (int) $stmt->fetchColumn(); +} + +/** + * Eigenes Ticket-Formular einer Abteilung. Gibt null zurück, wenn die Abteilung das Standardformular nutzt. + * Sonst die Texte/Optionen (mit Standardwerten ergänzt) und die Felder der Abteilung. + * + * @return array{title:string,intro:string,subject_show:bool,subject_label:string,message_label:string,message_hint:string,attachment_show:bool,submit_label:string,fields:array}|null + */ +function department_form(PDO $pdo, ?int $departmentId): ?array +{ + if (!$departmentId) return null; + $stmt = $pdo->prepare('SELECT name, own_form, form_config FROM departments WHERE id = ?'); + $stmt->execute([$departmentId]); + $d = $stmt->fetch(); + if (!$d || empty($d['own_form'])) return null; + $cfg = json_decode((string) ($d['form_config'] ?? ''), true); + $cfg = is_array($cfg) ? $cfg : []; + $str = fn(string $k, string $def) => (isset($cfg[$k]) && is_string($cfg[$k]) && trim($cfg[$k]) !== '') ? trim($cfg[$k]) : $def; + $fields = $pdo->prepare('SELECT * FROM custom_fields WHERE department_id = ? AND is_active = 1 ORDER BY sort_order, label'); + $fields->execute([$departmentId]); + $out = [ + 'title' => $str('title', ''), + 'intro' => $str('intro', ''), + 'subject_show' => !array_key_exists('subject_show', $cfg) || !empty($cfg['subject_show']), + 'subject_label' => $str('subject_label', 'Betreff'), + 'message_label' => $str('message_label', 'Beschreibung'), + 'message_show' => !array_key_exists('message_show', $cfg) || !empty($cfg['message_show']), + 'subject_width' => in_array($cfg['subject_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['subject_width'] : 'full', + 'message_width' => in_array($cfg['message_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['message_width'] : 'full', + 'message_hint' => $str('message_hint', 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…'), + 'attachment_show' => !array_key_exists('attachment_show', $cfg) || !empty($cfg['attachment_show']), + 'submit_label' => $str('submit_label', 'Ticket erstellen'), + 'department_name' => (string) $d['name'], + 'contact_title' => $str('contact_title', 'Ihre Kontaktdaten'), + 'issue_title' => $str('issue_title', 'Ihr Anliegen'), + 'details_title' => $str('details_title', 'Ihr Anliegen'), + 'fields' => [], + ]; + $form = $out; + foreach ($fields->fetchAll() as $f) { + $sec = trim((string) ($f['form_section'] ?? '')); + if ($sec === '') $f['_section'] = 'extra'; + elseif ($sec === '@topic') $f['_section'] = 'topic'; + elseif ($sec === '@topic_top') $f['_section'] = 'topic_top'; + elseif ($sec === '@contact_top') $f['_section'] = 'contact_top'; + elseif ($sec === '@contact') $f['_section'] = 'contact'; + elseif ($sec === '@details') $f['_section'] = 'issue'; + elseif ($sec === '@details_top') $f['_section'] = 'issue_top'; + elseif ($sec === '@details_mid') $f['_section'] = 'issue_mid'; + elseif (mb_strtolower($sec) === mb_strtolower($form['contact_title'])) $f['_section'] = 'contact'; + elseif (in_array(mb_strtolower($sec), [mb_strtolower($form['issue_title']), mb_strtolower($form['details_title'])], true)) $f['_section'] = 'issue'; + else $f['_section'] = $sec; + $f['_section_title'] = in_array($f['_section'], ['topic', 'topic_top'], true) ? $form['issue_title'] : ($f['_section'] === 'extra' ? 'Zusätzliche Angaben' : (in_array($f['_section'], ['contact', 'contact_top'], true) ? $form['contact_title'] : (in_array($f['_section'], ['issue', 'issue_top', 'issue_mid'], true) ? $form['details_title'] : $sec))); + $form['fields'][] = $f; + } + return $form; +} + +/** + * Hinweistext eines Formularfeldes als sicheres HTML: Text wird maskiert, Links schreibt man als [Text](https://adresse) + * (nur http, https oder Adressen auf dieser Seite, die mit / beginnen). Zeilenumbrüche bleiben erhalten. + */ +function form_help_html(?string $text, string $linkLabel = ''): string +{ + // Steht nur eine Adresse im Text, wird die Bezeichnung des Feldes zum Link (statt der nackten Adresse) + if ($linkLabel !== '' && preg_match('#^\s*(https?://|/)[^\s)"\'<>]{1,500}\s*$#', (string) $text)) { + $text = '[' . $linkLabel . '](' . trim((string) $text) . ')'; + } + $html = e((string) $text); + $html = preg_replace_callback('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', function ($m) { + return '' . $m[1] . ''; + }, $html); + // Adressen ohne [Text](…) werden ebenfalls anklickbar (nicht, wenn sie schon in einem Link stehen) + $html = preg_replace_callback('~(]*>.*?)|(https?://[^\s<>"\']+)~u', function ($m) { + if (!empty($m[1])) return $m[1]; + $url = rtrim($m[2], '.,;:!?)'); + $rest = substr($m[2], strlen($url)); + return '' . $url . '' . $rest; + }, $html); + return nl2br($html, false); +} + +/** Setzt Hinweistext, Link-Adresse und verlinkten Textteil (Eingabefelder im Admin) zu gespeichertem Text mit [Wort](Adresse) zusammen. */ +function form_help_compose(string $text, string $url, string $word, string $fallbackLabel = ''): string +{ + $text = trim($text); + $url = trim($url); + $word = trim($word); + if (preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $text) && $url === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $text . ')'; + if ($url === '' || !preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $url)) return mb_substr($text, 0, 1000); + if ($text === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $url . ')'; + if ($word !== '' && ($pos = mb_stripos($text, $word)) !== false) { + $found = mb_substr($text, $pos, mb_strlen($word)); + return mb_substr(mb_substr($text, 0, $pos) . '[' . $found . '](' . $url . ')' . mb_substr($text, $pos + mb_strlen($word)), 0, 1000); + } + return mb_substr($text . ' [' . ($word !== '' ? $word : 'Mehr erfahren') . '](' . $url . ')', 0, 1000); +} + +/** Gegenstück zu form_help_compose(): zerlegt gespeicherten Text in [Text ohne Link-Syntax, Adresse, verlinkter Teil]. */ +function form_help_split(?string $stored): array +{ + $stored = (string) $stored; + if (preg_match('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', $stored, $m, PREG_OFFSET_CAPTURE)) { + $plain = substr($stored, 0, $m[0][1]) . $m[1][0] . substr($stored, $m[0][1] + strlen($m[0][0])); + return [$plain, $m[2][0], $m[1][0]]; + } + return [$stored, '', '']; +} diff --git a/DeskHub-Webseite/includes/header_customer.php b/DeskHub-Webseite/includes/header_customer.php index 8e1c6087..ab63b05e 100644 --- a/DeskHub-Webseite/includes/header_customer.php +++ b/DeskHub-Webseite/includes/header_customer.php @@ -19,14 +19,11 @@ if (get_setting('helpdesk_status', 'online') === 'offline') { <?= e($displayName) ?> – Wartungsmodus "> - - + - -