From 793fe6162e72fab7f3bd462245e5fcd6253063b0 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sat, 24 Oct 2026 20:14:41 +0000 Subject: [PATCH] Upload via GUI (335 Dateien) --- DeskHub-Webseite/staff/_footer.php | 1 + DeskHub-Webseite/staff/_header.php | 13 +- DeskHub-Webseite/staff/reset_password.php | 1 + DeskHub-Webseite/staff/settings.php | 2453 +++++++++-------- DeskHub-Webseite/staff/status_page.php | 110 + DeskHub-Webseite/staff/ticket_create.php | 28 + DeskHub-Webseite/staff/ticket_templates.php | 84 + DeskHub-Webseite/staff/ticket_view.php | 15 + DeskHub-Webseite/tests/api_csat_status.php | 166 ++ DeskHub-Webseite/tests/catalog_preview.php | 15 + .../tests/csat_status_templates.php | 161 ++ DeskHub-Webseite/tests/error_alert.php | 86 + 12 files changed, 1920 insertions(+), 1213 deletions(-) create mode 100644 DeskHub-Webseite/staff/status_page.php create mode 100644 DeskHub-Webseite/staff/ticket_templates.php create mode 100644 DeskHub-Webseite/tests/api_csat_status.php create mode 100644 DeskHub-Webseite/tests/csat_status_templates.php create mode 100644 DeskHub-Webseite/tests/error_alert.php diff --git a/DeskHub-Webseite/staff/_footer.php b/DeskHub-Webseite/staff/_footer.php index 685f98f2..1586cc3d 100644 --- a/DeskHub-Webseite/staff/_footer.php +++ b/DeskHub-Webseite/staff/_footer.php @@ -1,5 +1,6 @@ + diff --git a/DeskHub-Webseite/staff/_header.php b/DeskHub-Webseite/staff/_header.php index 60ebda8c..25172b19 100644 --- a/DeskHub-Webseite/staff/_header.php +++ b/DeskHub-Webseite/staff/_header.php @@ -36,8 +36,11 @@ if ('serviceWorker' in navigator) { + +
-
+
; }; })(); +
+ $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?>
diff --git a/DeskHub-Webseite/staff/reset_password.php b/DeskHub-Webseite/staff/reset_password.php index bf6a5b73..693012f2 100644 --- a/DeskHub-Webseite/staff/reset_password.php +++ b/DeskHub-Webseite/staff/reset_password.php @@ -42,6 +42,7 @@ $pageTitle = 'Passwort zurücksetzen'; +
diff --git a/DeskHub-Webseite/staff/settings.php b/DeskHub-Webseite/staff/settings.php index 8a55bc72..ff300037 100644 --- a/DeskHub-Webseite/staff/settings.php +++ b/DeskHub-Webseite/staff/settings.php @@ -1,1212 +1,1241 @@ - ['label' => 'E-Mail-Abruf', 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'], - 'check_sla' => ['label' => 'SLA-Prüfung', 'file' => 'check_sla.php', 'fn' => 'run_check_sla'], - 'check_escalations' => ['label' => 'Eskalationsregeln', 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'], - 'process_recurring' => ['label' => 'Wiederkehrende Tickets', 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'], - 'process_backups' => ['label' => 'Automatische Backups', 'file' => 'process_backups.php', 'fn' => 'run_process_backups'], - 'anonymize_data' => ['label' => 'Datenanonymisierung', 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'], - 'auto_close_tickets'=> ['label' => 'Automatisches Schließen', 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'], - 'process_snoozes' => ['label' => 'Wiedervorlagen', 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'], - 'remind_customers' => ['label' => 'Kundenerinnerung', 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'], -]; - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - verify_csrf(); - $action = $_POST['action'] ?? ''; - - if ($action === 'save_system') { - $status = ($_POST['helpdesk_status'] ?? 'online') === 'offline' ? 'offline' : 'online'; - $offlineMsg = trim($_POST['offline_message'] ?? ''); - $defaultDept = (int)($_POST['default_department_id'] ?? 0); - $kbEnabled = !empty($_POST['kb_enabled']) ? '1' : '0'; - if (!in_array($defaultDept, array_column($departments, 'id'))) { - $errors[] = 'Bitte eine gültige Standardabteilung wählen.'; - } else { - set_setting('helpdesk_status', $status); - set_setting('offline_message', $offlineMsg ?: 'Das Supportcenter ist derzeit nicht erreichbar.'); - set_setting('default_department_id', (string)$defaultDept); - set_setting('kb_enabled', $kbEnabled); - set_setting('ticket_ratings_enabled', !empty($_POST['ticket_ratings_enabled']) ? '1' : '0'); - set_setting('auto_time_tracking_enabled', !empty($_POST['auto_time_tracking_enabled']) ? '1' : '0'); - set_setting('auto_assign_on_open', !empty($_POST['auto_assign_on_open']) ? '1' : '0'); - set_setting('live_chat_enabled', !empty($_POST['live_chat_enabled']) ? '1' : '0'); - $liveChatDept = $_POST['live_chat_department_id'] ?? ''; - set_setting('live_chat_department_id', ($liveChatDept !== '' && in_array((int)$liveChatDept, array_column($departments, 'id'))) ? (string)(int)$liveChatDept : ''); - set_setting('live_chat_send_transcript', !empty($_POST['live_chat_send_transcript']) ? '1' : '0'); - set_setting('auto_close_enabled', !empty($_POST['auto_close_enabled']) ? '1' : '0'); - $autoCloseDays = max(1, min(90, (int) ($_POST['auto_close_days'] ?? 7))); - set_setting('auto_close_days', (string) $autoCloseDays); - set_setting('remind_enabled', !empty($_POST['remind_enabled']) ? '1' : '0'); - set_setting('remind_after_days', (string) max(1, min(60, (int) ($_POST['remind_after_days'] ?? 3)))); - $timeout = max(5, min(480, (int)($_POST['staff_session_timeout'] ?? 30))); - set_setting('staff_session_timeout', (string)$timeout); - audit_log($staff['id'], "Systemeinstellungen geändert"); - flash('success', 'Systemeinstellungen gespeichert.'); - redirect('/staff/settings.php?tab=allgemein'); - } - } - - if ($action === 'save_privacy') { - $enabled = !empty($_POST['retention_enabled']) ? '1' : '0'; - $months = max(1, min(120, (int)($_POST['retention_months'] ?? 24))); - $ipAnon = !empty($_POST['ip_anonymize']) ? '1' : '0'; - set_setting('retention_enabled', $enabled); - set_setting('retention_months', (string)$months); - set_setting('ip_anonymize', $ipAnon); - audit_log($staff['id'], 'Datenschutzeinstellungen geändert'); - flash('success', 'Datenschutzeinstellungen gespeichert.'); - redirect('/staff/settings.php?tab=datenschutz'); - } - - if ($action === 'run_anonymize_now') { - define('DESKHUB_CRON_RUNNER', true); - require_once __DIR__ . '/../cron/anonymize_data.php'; - $result = run_anonymization(get_db(), true); - audit_log($staff['id'], "Datenanonymisierung manuell ausgeführt: {$result['count']} Ticket(s)"); - flash('success', "{$result['count']} Ticket(s) anonymisiert."); - redirect('/staff/settings.php?tab=datenschutz'); - } - - // Manueller Start eines Cronjobs aus dem Backend ("Jetzt ausführen"-Button, Tab Cron-Jobs). - // Führt exakt die gleiche Logik aus wie der echte Cronjob, nur direkt per Klick statt per crontab. - if ($action === 'run_cron_now') { - $job = $_POST['job'] ?? ''; - if (!isset($cronJobs[$job])) { - flash('error', 'Unbekannter Cronjob.'); - redirect('/staff/settings.php?tab=cron'); - } - $meta = $cronJobs[$job]; - define('DESKHUB_CRON_RUNNER', true); - require_once __DIR__ . '/../cron/' . $meta['file']; - $fn = $meta['fn']; - - try { - if ($job === 'anonymize_data') { - $result = $fn(get_db(), true); - $summary = "{$result['count']} Ticket(s) anonymisiert."; - } elseif ($job === 'process_backups') { - // Manueller Klick erzwingt ein Backup unabhängig vom Zeitplan. - $result = $fn(get_db(), true, true); - $summary = $result['ok'] ? ($result['ran'] ? 'Backup erstellt.' : 'Kein Backup nötig.') : 'Backup fehlgeschlagen.'; - } else { - $result = $fn(get_db(), true); - $summary = end($result['log']) ?: 'Ausgeführt.'; - } - audit_log($staff['id'], "Cronjob manuell ausgeführt: {$meta['label']} – {$summary}"); - flash('success', "{$meta['label']}: {$summary}"); - } catch (Throwable $e) { - audit_log($staff['id'], "Cronjob manuell fehlgeschlagen: {$meta['label']} – " . $e->getMessage()); - flash('error', "{$meta['label']} fehlgeschlagen: " . $e->getMessage()); - } - redirect('/staff/settings.php?tab=cron'); - } - - // Ein-/Ausschalten des eingebauten Auto-Cron (siehe includes/pseudo_cron.php). - if ($action === 'save_pseudo_cron') { - $enabled = !empty($_POST['pseudo_cron_enabled']) ? '1' : '0'; - set_setting('pseudo_cron_enabled', $enabled); - audit_log($staff['id'], $enabled === '1' ? 'Automatischer Hintergrund-Cron aktiviert' : 'Automatischer Hintergrund-Cron deaktiviert'); - flash('success', 'Gespeichert.'); - redirect('/staff/settings.php?tab=cron'); - } - - // Echte System-Crontab installieren/entfernen (nur auf Servern mit Shell-Zugriff möglich, - // siehe includes/crontab_installer.php – schreibt ausschließlich feste, fest hinterlegte Zeilen). - if ($action === 'install_crontab') { - $result = crontab_install(); - if ($result['ok']) { - audit_log($staff['id'], 'Server-Crontab installiert/aktualisiert (6 DeskHub-Cronjobs)'); - flash('success', 'Crontab installiert. Die 6 Cronjobs laufen jetzt direkt über den Server-Scheduler.'); - } else { - audit_log($staff['id'], 'Server-Crontab-Installation fehlgeschlagen: ' . $result['error']); - flash('error', 'Installation fehlgeschlagen: ' . $result['error']); - } - redirect('/staff/settings.php?tab=cron'); - } - - if ($action === 'remove_crontab') { - $result = crontab_uninstall(); - if ($result['ok']) { - audit_log($staff['id'], 'Server-Crontab-Eintrag von DeskHub entfernt'); - flash('success', 'Crontab-Eintrag entfernt.'); - } else { - audit_log($staff['id'], 'Entfernen der Server-Crontab fehlgeschlagen: ' . $result['error']); - flash('error', 'Entfernen fehlgeschlagen: ' . $result['error']); - } - redirect('/staff/settings.php?tab=cron'); - } - - if ($action === 'save_name') { - $name = trim($_POST['display_name'] ?? ''); - if (!$name) { $errors[] = 'Bitte einen Anzeigenamen angeben.'; } - else { - set_setting('display_name', $name); - $siteUrl = rtrim(trim($_POST['site_url'] ?? ''), '/'); - if ($siteUrl) set_setting('site_url', $siteUrl); - $tz = trim($_POST['timezone'] ?? ''); - if ($tz && in_array($tz, timezone_identifiers_list(), true)) set_setting('timezone', $tz); - set_setting('mail_enabled', !empty($_POST['mail_enabled']) ? '1' : '0'); - audit_log($staff['id'], "Anzeigename / Basis-Einstellungen geändert: $name"); - flash('success', 'Einstellungen gespeichert.'); - redirect('/staff/settings.php?tab=allgemein'); - } - } - - if ($action === 'upload_logo') { - $file = $_FILES['logo'] ?? []; - $allowed = ['png','jpg','jpeg','gif','svg']; - $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); - if (empty($file['name'])) { $errors[] = 'Bitte eine Bilddatei auswählen.'; } - elseif ($file['error'] !== UPLOAD_ERR_OK) { $errors[] = 'Upload fehlgeschlagen.'; } - elseif ($file['size'] > 2*1024*1024) { $errors[] = 'Max. 2 MB.'; } - elseif (!in_array($ext, $allowed, true)) { $errors[] = 'Erlaubt: PNG, JPG, GIF, SVG.'; } - else { - foreach ($allowed as $e2) @unlink($branding.'/logo.'.$e2); - $dest = $branding.'/logo.'.$ext; - if (!move_uploaded_file($file['tmp_name'], $dest)) { $errors[] = 'Speichern fehlgeschlagen.'; } - else { set_setting('logo_path', '/assets/branding/logo.'.$ext.'?v='.time()); audit_log($staff['id'],'Logo aktualisiert'); flash('success','Logo aktualisiert.'); redirect('/staff/settings.php?tab=design'); } - } - } - if ($action === 'upload_favicon') { - $file = $_FILES['favicon'] ?? []; - $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); - $err = null; - if (empty($file['name'])) $err = 'Bitte eine Datei auswählen.'; - elseif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) $err = 'Upload fehlgeschlagen' . (($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE ? ' (die Datei ist größer als das Upload-Limit des Servers).' : '.'); - elseif ($file['size'] > 1024 * 1024) $err = 'Das Favicon darf höchstens 1 MB groß sein.'; - elseif (!isset(favicon_types()[$ext])) $err = 'Erlaubt: ICO, PNG, JPG, GIF, WebP, SVG.'; - else $err = favicon_validate_file($file['tmp_name'], $ext); - if ($err !== null) { $errors[] = $err; } - else { - foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); - if (!move_uploaded_file($file['tmp_name'], $branding . '/favicon.' . $ext)) { $errors[] = 'Speichern fehlgeschlagen (Ordner assets/branding/ nicht beschreibbar?).'; } - else { set_setting('favicon_path', '/assets/branding/favicon.' . $ext . '?v=' . time()); audit_log($staff['id'], 'Favicon aktualisiert'); flash('success', 'Favicon aktualisiert. Browser zeigen Änderungen oft erst nach Neuladen (Strg+F5) oder in einem neuen Tab.'); redirect('/staff/settings.php?tab=design'); } - } - } - if ($action === 'remove_favicon') { - foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); - set_setting('favicon_path', null); audit_log($staff['id'], 'Favicon entfernt'); flash('success', 'Favicon entfernt.'); redirect('/staff/settings.php?tab=design'); - } - if ($action === 'save_logo_options') { - $hide = !empty($_POST['logo_hide_name']) ? '1' : '0'; - set_setting('logo_hide_name', $hide); - audit_log($staff['id'], $hide === '1' ? 'Name neben dem Logo ausgeblendet' : 'Name neben dem Logo wird angezeigt'); - flash('success', $hide === '1' ? 'Der Name wird neben dem Logo nicht mehr angezeigt.' : 'Der Name wird wieder neben dem Logo angezeigt.'); - redirect('/staff/settings.php?tab=design'); - } - if ($action === 'remove_logo') { - foreach (['png','jpg','jpeg','gif','svg'] as $e2) @unlink($branding.'/logo.'.$e2); - set_setting('logo_path', null); audit_log($staff['id'],'Logo entfernt'); flash('success','Logo entfernt.'); redirect('/staff/settings.php?tab=design'); - } - if ($action === 'upload_banner') { - $file = $_FILES['banner'] ?? []; - $allowed = ['png','jpg','jpeg','gif']; - $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); - if (empty($file['name'])) { $errors[] = 'Bitte eine Bilddatei auswählen.'; } - elseif ($file['error'] !== UPLOAD_ERR_OK) { $errors[] = 'Upload fehlgeschlagen.'; } - elseif ($file['size'] > 5*1024*1024) { $errors[] = 'Max. 5 MB.'; } - elseif (!in_array($ext, $allowed, true)) { $errors[] = 'Erlaubt: PNG, JPG, GIF.'; } - else { - foreach ($allowed as $e2) @unlink($branding.'/banner.'.$e2); - $dest = $branding.'/banner.'.$ext; - if (!move_uploaded_file($file['tmp_name'], $dest)) { $errors[] = 'Speichern fehlgeschlagen.'; } - else { set_setting('banner_path', '/assets/branding/banner.'.$ext.'?v='.time()); audit_log($staff['id'],'Titelbild aktualisiert'); flash('success','Titelbild aktualisiert.'); redirect('/staff/settings.php?tab=design'); } - } - } - if ($action === 'remove_banner') { - foreach (['png','jpg','jpeg','gif'] as $e2) @unlink($branding.'/banner.'.$e2); - set_setting('banner_path', null); audit_log($staff['id'],'Titelbild entfernt'); flash('success','Titelbild entfernt.'); redirect('/staff/settings.php?tab=design'); - } - if ($action === 'save_homepage_content') { - $popularArticlesEnabled = !empty($_POST['homepage_popular_articles_enabled']) ? '1' : '0'; - set_setting('homepage_popular_articles_enabled', $popularArticlesEnabled); - audit_log( - $staff['id'], - $popularArticlesEnabled === '1' - ? 'Beliebte Artikel auf der Startseite eingeblendet' - : 'Beliebte Artikel auf der Startseite ausgeblendet' - ); - flash('success', 'Startseiten-Inhalte gespeichert.'); - redirect('/staff/settings.php?tab=design'); - } - if ($action === 'upload_custom_theme') { - try { - $theme = install_custom_theme_upload($_FILES['theme_zip'] ?? []); - $actionLabel = $theme['replaced'] ? 'aktualisiert' : 'hochgeladen'; - audit_log($staff['id'], "Kundenportal-Theme {$actionLabel}: {$theme['name']}"); - flash('success', "Theme „{$theme['name']}“ wurde {$actionLabel}."); - redirect('/staff/settings.php?tab=design'); - } catch (Throwable $e) { - $errors[] = $e->getMessage(); - } - } - if ($action === 'save_theme_catalog' && plugin_strict_mode()) { - $errors[] = 'Im geprüften Modus ist der Katalog fest vorgegeben.'; - } elseif ($action === 'save_theme_catalog') { - $url = trim((string) ($_POST['theme_catalog_url'] ?? '')); - if ($url !== '' && ($urlError = plugin_url_check($url))) { - $errors[] = 'Katalog-Adresse: ' . $urlError; - } else { - set_setting('theme_catalog_url', $url); - flash('success', 'Katalog-Adresse gespeichert.'); - redirect('/staff/settings.php?tab=design'); - } - } - if ($action === 'theme_catalog_install') { - $res = theme_catalog_install((string) ($_POST['slug'] ?? '')); - if ($res['ok']) { - audit_log($staff['id'], "Kundenportal-Theme aus dem Katalog installiert: {$res['theme']['name']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')'); - flash('success', "Theme „{$res['theme']['name']}“ installiert (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.'); - redirect('/staff/settings.php?tab=design'); - } - $errors[] = $res['error']; - } - if ($action === 'activate_custom_theme') { - $themeId = (string) ($_POST['theme_id'] ?? ''); - $theme = null; - foreach (list_custom_themes() as $candidate) { - if ($candidate['id'] === $themeId) { - $theme = $candidate; - break; - } - } - if ($theme === null) { - $errors[] = 'Das ausgewählte Theme wurde nicht gefunden.'; - } else { - set_setting('active_custom_theme', $themeId); - audit_log($staff['id'], "Kundenportal-Theme aktiviert: {$theme['name']}"); - flash('success', "Theme „{$theme['name']}“ ist jetzt aktiv."); - redirect('/staff/settings.php?tab=design'); - } - } - if ($action === 'deactivate_custom_theme') { - set_setting('active_custom_theme', null); - audit_log($staff['id'], 'Benutzerdefiniertes Kundenportal-Theme deaktiviert'); - flash('success', 'Das Standarddesign ist jetzt aktiv.'); - redirect('/staff/settings.php?tab=design'); - } - if ($action === 'delete_custom_theme') { - $themeId = (string) ($_POST['theme_id'] ?? ''); - $theme = null; - foreach (list_custom_themes() as $candidate) { - if ($candidate['id'] === $themeId) { - $theme = $candidate; - break; - } - } - if ($theme === null || !delete_custom_theme($themeId)) { - $errors[] = 'Das Theme konnte nicht gelöscht werden.'; - } else { - if (get_setting('active_custom_theme', '') === $themeId) { - set_setting('active_custom_theme', null); - } - audit_log($staff['id'], "Kundenportal-Theme gelöscht: {$theme['name']}"); - flash('success', "Theme „{$theme['name']}“ wurde gelöscht."); - redirect('/staff/settings.php?tab=design'); - } - } - - if ($action === 'save_uploads') { - $dangerous = ['php','php3','php4','php5','phtml','phar','exe','bat','cmd','sh','pl','py','asp','aspx','jsp','cgi']; - $extList = array_filter(array_map('trim', explode(',', strtolower($_POST['upload_allowed_extensions'] ?? '')))); - $extList = array_diff($extList, $dangerous); - set_setting('upload_allowed_extensions', implode(', ', $extList)); - set_setting('upload_max_size_mb', max(1, min(100, (int)($_POST['upload_max_size_mb'] ?? 10)))); - audit_log($staff['id'], 'Upload-Einstellungen geändert'); - flash('success', 'Upload-Einstellungen gespeichert.'); - redirect('/staff/settings.php?tab=uploads'); - } - - if ($action === 'save_footer_pages') { - foreach (['footer_privacy_label','footer_privacy_url','footer_privacy_html','footer_imprint_label','footer_imprint_url','footer_imprint_html'] as $k) - set_setting($k, trim($_POST[$k] ?? '') ?: null); - audit_log($staff['id'], 'Footer-Seiten geändert'); - flash('success', 'Footer-Einstellungen gespeichert.'); - redirect('/staff/settings.php?tab=footer'); - } - - if ($action === 'save_notifications') { - $discordNew = trim($_POST['discord_webhook_url'] ?? ''); - $tgTokenNew = trim($_POST['telegram_bot_token'] ?? ''); - $tgChatId = trim($_POST['telegram_chat_id'] ?? ''); - // Leer lassen = unverändert; Wert "-" = explizit löschen; sonst verschlüsseln - if ($discordNew !== '') { - set_setting('discord_webhook_url', $discordNew === '-' ? null : encrypt_secret($discordNew)); - } - if ($tgTokenNew !== '') { - set_setting('telegram_bot_token', $tgTokenNew === '-' ? null : encrypt_secret($tgTokenNew)); - } - if ($tgChatId !== '') { - set_setting('telegram_chat_id', $tgChatId === '-' ? null : $tgChatId); - } - audit_log($staff['id'], 'Benachrichtigungen geändert'); - flash('success', 'Benachrichtigungseinstellungen gespeichert.'); - redirect('/staff/settings.php?tab=benachrichtigungen'); - } - // Push-Benachrichtigungen für die Android-App (Firebase Cloud Messaging) - if ($action === 'save_fcm') { - set_setting('fcm_enabled', !empty($_POST['fcm_enabled']) ? '1' : '0'); - set_setting('fcm_show_details', !empty($_POST['fcm_show_details']) ? '1' : '0'); - set_setting('push_brand', fcm_clean_brand((string) ($_POST['push_brand'] ?? ''))); - flash('success', 'Einstellungen für die Android-Benachrichtigungen gespeichert.'); - redirect('/staff/settings.php?tab=benachrichtigungen'); - } - if ($action === 'remove_fcm') { - set_setting('fcm_service_account', ''); - set_setting('fcm_access_token', ''); - audit_log($staff['id'], 'Firebase-Dienstkonto für Android-Push entfernt'); - flash('success', 'Der Firebase-Schlüssel wurde vom Server entfernt.'); - redirect('/staff/settings.php?tab=benachrichtigungen'); - } - if ($action === 'test_fcm') { - if (fcm_mode() === 'none') { flash('error', 'Push ist nicht eingerichtet: Es ist kein Push-Dienst eingetragen und kein eigener Firebase-Schlüssel hinterlegt.'); redirect('/staff/settings.php?tab=benachrichtigungen'); } - $t = fcm_send_test($pdo, (int) $staff['id']); - if ($t['devices'] === 0) { flash('error', 'Auf deinem Konto ist noch kein Android-Gerät angemeldet. Melde dich in der App an (Benachrichtigungen erlauben) und versuche es erneut.'); redirect('/staff/settings.php?tab=benachrichtigungen'); } - $t['sent'] > 0 ? flash('success', "Testnachricht an {$t['sent']} Gerät(e) gesendet. Sie sollte jetzt auf dem Handy erscheinen.") : flash('error', 'Senden fehlgeschlagen: ' . ($t['error'] !== '' ? $t['error'] : 'unbekannter Fehler')); - redirect('/staff/settings.php?tab=benachrichtigungen'); - } - - if ($action === 'test_notifications') { - $sent = []; - if (discord_notify('🔔 Testnachricht von DeskHub')) $sent[] = 'Discord'; - if (telegram_notify('🔔 Testnachricht von DeskHub')) $sent[] = 'Telegram'; - $sent ? flash('success', 'Gesendet an: '.implode(', ', $sent)) : flash('error', 'Kein Kanal konfiguriert oder Fehler.'); - redirect('/staff/settings.php?tab=benachrichtigungen'); - } - - if ($action === 'save_smtp') { - set_setting('smtp_host', trim($_POST['smtp_host'] ?? '') ?: null); - set_setting('smtp_port', trim($_POST['smtp_port'] ?? '') ?: null); - set_setting('smtp_encryption', $_POST['smtp_encryption'] ?? 'tls'); - set_setting('smtp_username', trim($_POST['smtp_username'] ?? '') ?: null); - if (trim($_POST['smtp_password'] ?? '') !== '') set_setting('smtp_password', encrypt_secret(trim($_POST['smtp_password']))); - set_setting('smtp_from_name', trim($_POST['smtp_from_name'] ?? '') ?: null); - set_setting('smtp_from_email', trim($_POST['smtp_from_email'] ?? '') ?: null); - audit_log($staff['id'], 'SMTP geändert'); - flash('success', 'SMTP-Einstellungen gespeichert.'); - redirect('/staff/settings.php?tab=email'); - } - if ($action === 'test_smtp') { - $testBody = email_wrap(email_p('Diese Test-E-Mail bestätigt, dass Ihr SMTP-Versand korrekt eingerichtet ist. 🎉')); - $ok = send_mail($staff['email'], 'DeskHub – Test-E-Mail', $testBody); - $ok ? flash('success', 'Test-Mail an '.$staff['email'].' gesendet.') : flash('error', 'Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. '.smtp_last_error()); - redirect('/staff/settings.php?tab=email'); - } - // Einzelnes globales IMAP-Konto wurde durch die Tabelle imap_accounts abgelöst - // (mehrere Postfächer möglich, siehe staff/imap_accounts.php). Die alten - // Einzel-Settings (imap_host/imap_user/...) werden nur noch einmalig per - // Migration php_0011_imap_accounts als erstes Postfach übernommen. -} - -// ── Lazy-Encryption: Klartext-Credentials beim ersten Admin-Besuch sichern ── -// Direkt über PDO (nicht get_setting) um den statischen Cache zu umgehen. -// Idempotent: AES-GCM-Blobs (base64, min. 28 Bytes) werden nicht erneut verschlüsselt. -foreach (['discord_webhook_url', 'telegram_bot_token', 'smtp_password', 'imap_pass'] as $_lek) { - $stmt_le = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); - $stmt_le->execute([$_lek]); - $raw_le = $stmt_le->fetchColumn(); - if ($raw_le !== false && $raw_le !== '' && $raw_le !== null) { - $decoded_le = base64_decode($raw_le, true); - if ($decoded_le === false || strlen($decoded_le) < 28) { - // Klartext gefunden → jetzt verschlüsseln - $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ?') - ->execute([encrypt_secret($raw_le), $_lek]); - } - } -} -unset($_lek, $stmt_le, $raw_le, $decoded_le); - -// ── Werte laden ─────────────────────────────────────────────────────────────── -$currentLogo = get_setting('logo_path'); -$currentBanner = get_setting('banner_path'); -$homepagePopularArticlesEnabled = get_setting('homepage_popular_articles_enabled', '1') === '1'; -$currentName = get_setting('display_name', config()['site']['name']); -$helpdeskStatus = get_setting('helpdesk_status', 'online'); -$offlineMessage = get_setting('offline_message', 'Das Supportcenter ist derzeit nicht erreichbar.'); -$defaultDeptId = get_setting('default_department_id', (string)($departments[0]['id'] ?? '')); -$kbEnabled = get_setting('kb_enabled', '1') === '1'; -$discordUrl = get_setting('discord_webhook_url', ''); -$telegramToken = get_setting('telegram_bot_token', ''); -$telegramChatId = get_setting('telegram_chat_id', ''); -$smtpHost = get_setting('smtp_host', ''); -$smtpPort = get_setting('smtp_port', '587'); -$smtpEncryption = get_setting('smtp_encryption', 'tls'); -$smtpUsername = get_setting('smtp_username', ''); -$smtpFromName = get_setting('smtp_from_name', ''); -$smtpFromEmail = get_setting('smtp_from_email', ''); -$footerPrivacyLabel = get_setting('footer_privacy_label', 'Datenschutz'); -$footerPrivacyUrl = get_setting('footer_privacy_url', ''); -$footerPrivacyHtml = get_setting('footer_privacy_html', ''); -$footerImprintLabel = get_setting('footer_imprint_label', 'Impressum'); -$footerImprintUrl = get_setting('footer_imprint_url', ''); -$footerImprintHtml = get_setting('footer_imprint_html', ''); -$uploadExtensions = get_setting('upload_allowed_extensions', implode(', ', config()['uploads']['allowed_extensions'])); -$uploadMaxSize = get_setting('upload_max_size_mb', '10'); -$customThemes = list_custom_themes(); -$activeCustomTheme = get_setting('active_custom_theme', ''); - -$cronFetch = cron_last_run_label(get_setting('cron_fetch_emails_last_run')); -$cronSla = cron_last_run_label(get_setting('cron_check_sla_last_run')); -$cronRecurring = cron_last_run_label(get_setting('cron_process_recurring_last_run')); -$cronBackups = cron_last_run_label(get_setting('cron_process_backups_last_run')); -$cronEscalations = cron_last_run_label(get_setting('cron_check_escalations_last_run')); -$cronAnonymize = cron_last_run_label(get_setting('cron_anonymize_data_last_run')); -$cronAutoClose = cron_last_run_label(get_setting('cron_auto_close_tickets_last_run')); -$cronSnoozes = cron_last_run_label(get_setting('cron_process_snoozes_last_run')); -$cronRemind = cron_last_run_label(get_setting('cron_remind_customers_last_run')); - - -$tab = $_GET['tab'] ?? 'allgemein'; -$pageTitle = 'Einstellungen'; -$activePage = 'settings'; -require __DIR__ . '/_header.php'; -?> - -
-

Einstellungen

- 📥 osTicket Import -
- -
- - -
- 'Allgemein', - 'design' => 'Design', - 'email' => 'E-Mail', - 'benachrichtigungen' => 'Benachrichtigungen', - 'uploads' => 'Uploads', - 'footer' => 'Footer', - 'cron' => 'Cron-Jobs', - 'datenschutz' => '🔒 Datenschutz', - ]; - foreach ($tabs as $key => $label): - $active = $tab === $key; - ?> - - - - -
- - - - -
- -
-

System

-
- - - - - - - - - - - - - - -
-
- - Tage -
- - -
-
- - Tage (am besten kürzer als die Frist zum automatischen Schließen) -
- - -
- - - -
- -
- -
- - Minuten (5–480). Countdown wird oben rechts angezeigt. -
-
- -
-
-
- -
-

Anzeigename & URL

-

Wird im Browser-Tab, in E-Mails und in der Web-App angezeigt.

-
- - - - - - -

Wird in E-Mail-Links verwendet. Kein abschließendes /

- - - -
-
-
- -
- - - -
- -
-

Firmenlogo

-

PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. Der Name der Installation steht standardmäßig daneben und lässt sich unten ausblenden.

- -
- Logo -

-
- - -
-
- - -
-
- -

Kein Logo hinterlegt.

- -
- - -
-
-
- -
-

Favicon (Symbol im Browser-Tab)

-

ICO, PNG, JPG, GIF, WebP oder SVG, höchstens 1 MB, am besten quadratisch (z. B. 256×256). Ohne Favicon verwendet DeskHub das Logo.

- - -
- - Favicon - -
- - -
-
- -

Kein eigenes Favicon hinterlegt.

- -
- - -
-
-
- -
-

Titelbild (Hero-Banner)

-

Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.

- - Banner -
- - -
- -

Kein Titelbild hinterlegt.

- -
- - -
-
-
- -
- -
-

Startseiten-Inhalte

-

- Legen Sie fest, welche zusätzlichen Bereiche auf der Kundenportal-Startseite angezeigt werden. -

-
- - -
-
-
- -
-

Eigene Kundenportal-Themes

-

- Laden Sie mehrere Designs als ZIP hoch und aktivieren Sie eines davon global für alle Kunden. - Das ZIP muss im Stammverzeichnis eine theme.css enthalten. Zulässig sind CSS, - PNG, JPG, GIF, WebP, WOFF und WOFF2; maximal 10 MB. - Autor und Version können im CSS-Kopf mit Author: und Version: angegeben werden. -

- -
- -
- - -
- -
-

- Der Anzeigename wird automatisch aus dem ZIP-Dateinamen übernommen, zum Beispiel - Classic-Support-Blue.zip → Classic Support Blue. - Ein erneuter Upload mit demselben Namen ersetzt das vorhandene Theme. -

- - -

Noch keine eigenen Themes hochgeladen.

- -
- - - -
-
- · Version - - Aktiv - -
Von
-
-
- -
- - -
- -
- - - -
- -
- - - -
-
-
- -
- -
- -
-

Themes aus dem Katalog

-

Fertige Designs direkt installieren. Prüfsumme und Signatur werden vor der Installation geprüft.

- -

Katalog aktualisieren

- -
- -
-
- - Katalog aktualisieren -
- - - -
- - - - - - - - - - - - - - - -
ThemeVersionSignatur
-
von
installiert:
✓ Signiert' : 'Nicht signiert' ?> - ✓ Installiert - -
-
Der Katalog enthält keine Themes.
- - -
- - - -
- -
-

SMTP-Versand

-
- - - -
-
- - -
-
- - -
-
- - - - -
-
- - -
-
- - -
-
-
- -
-
-
- - -
-
- -
-

E-Mail-Abruf (IMAP)

-

Postfächer, aus denen eingehende E-Mails als Tickets importiert werden. Es können mehrere Postfächer angelegt werden (z.B. ein eigenes je Abteilung) – jedes mit eigenem Server, Zugangsdaten und optionaler Standardabteilung.

- query('SELECT COUNT(*) FROM imap_accounts WHERE is_active = 1')->fetchColumn(); ?> -

- 0): ?> - ● aktive(s) Postfach/Postfächer eingerichtet - - ○ Noch kein Postfach eingerichtet - - · Letzter Abruf: -

- Postfächer verwalten → -
- -
- - - -
-
-

Discord & Telegram

-

Mitarbeiter werden bei neuen Tickets und Antworten benachrichtigt. Ticket-Daten werden dabei an den jeweiligen Drittanbieter übermittelt.

-

- 🔒 Webhook-URL und Bot-Token werden verschlüsselt gespeichert. Leer lassen = unverändert. Wert - löscht ihn. -

-
- - - -

Kanal-Einstellungen → Integrationen → Webhooks → Neuer Webhook

- - - - -

Bot über @BotFather erstellen, Chat-ID via @userinfobot ermitteln.

-
- -
-
-
- - -
-
- -query('SELECT COUNT(*) FROM push_devices')->fetchColumn(); - $__fcmErr = (string) get_setting('fcm_last_error', ''); - $__fcmOk = (string) get_setting('fcm_last_ok', ''); - $__fcmFromConfig = (string) (config()['fcm']['service_account_file'] ?? '') !== ''; -?> -
-

📱 Android-App: Push-Benachrichtigungen

-

Popups auf dem Handy bei neuen Tickets, Kundenantworten und Zuweisungen, auch wenn die App geschlossen ist. Die Nachrichten laufen über Google (Firebase Cloud Messaging).

-

- - ✓ Eigenes Firebase-Projekt ·
- - ✓ Push ist aktiv · über den DeskHub-Push-Dienst, es ist nichts einzurichten
- - Nicht verfügbar. In dieser Version ist kein Push-Dienst eingetragen. Ihr könnt unten ein eigenes Firebase-Projekt verwenden.
- - Gerät(e) angemeldet -
Letzter Fehler: -

-
- - - - - -

Erscheint in jedem Popup neben dem App-Namen („DeskHub“). Den App-Namen selbst kann Android pro Server nicht ändern.

-
-
-
-
-
-
-
-
- - -
-

Erlaubte Dateitypen

-

Gefährliche Typen (PHP, EXE, SH etc.) werden immer blockiert – unabhängig von dieser Liste.

-
- - - -
- - - -
- -
- MB -
-
-
-
- - - -
-

Datenschutz & Impressum

-

Externer Link oder HTML-Inhalt (öffnet Popup). Ist ein Link gesetzt, wird das HTML-Feld ignoriert.

-
- -
-
-
🔒 Datenschutzerklärung
- - - - - - -
-
-
📄 Impressum
- - - - - - -
-
-
-
-
- - - -getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' - ? "SELECT COUNT(*) FROM tickets WHERE status IN ('closed','resolved') - AND updated_at < datetime('now', '-' || ? || ' months') - AND requester_email NOT LIKE 'anon_%'" - : "SELECT COUNT(*) FROM tickets WHERE status IN ('closed','resolved') - AND updated_at < DATE_SUB(NOW(), INTERVAL ? MONTH) - AND requester_email NOT LIKE 'anon_%'"; - $previewStmt = $pdo->prepare($previewSql); - $previewStmt->execute([$retMonths]); - $previewCount = (int)$previewStmt->fetchColumn(); -?> -
- -
-

Automatische Datenlöschung

-

- Geschlossene und gelöste Tickets werden nach der gewählten Zeit automatisch anonymisiert. - Name, E-Mail-Adresse und IP werden unkenntlich gemacht — das Ticket und seine Historie bleiben erhalten. -

-
- - - - - - -
- - Monate -
- - - -
- -
-
-
- -
-

Status & Vorschau

-
-
- - -
-

- Aktuell würden Ticket(s) anonymisiert - (geschlossen/gelöst, älter als Monate). -

-
- -

Was wird anonymisiert?

-
    -
  • Name → Anonymisiert
  • -
  • E-Mail → anon_[hash]@deleted.local
  • -
  • IP-Adresse → 0.0.0.0
  • -
  • Ticket-Nachrichten → bleiben erhalten
  • -
  • Ticket-Nummer, Betreff, Status → bleiben erhalten
  • -
- -

Crontab-Eintrag

-
0 3 * * * php /pfad/zu/deskhub/cron/anonymize_data.php
- -
- - - -
-
- -
- - - - -
-

🔄 Automatischer Hintergrund-Betrieb (ohne Server-Cronjob)

-

- Läuft komplett eingebaut, ganz ohne echten Server-Cronjob und ohne externen Dienst: - bei jeder Anmeldung/Nutzung im Backend und in der App wird im Hintergrund geprüft, - ob einer der 6 Jobs unten fällig ist, und automatisch ausgeführt. Solange regelmäßig jemand - das Backend oder die App nutzt, laufen alle Jobs von selbst dauerhaft weiter – ganz ohne - Einrichtung bei Strato oder einem externen Anbieter. -

-
-
- - - -
- - Status: ● aktiv' : '○ deaktiviert' ?> - · Letzte automatische Prüfung: - -
-
-
-
-

Cron-Status

-

Jeder Job kann hier zusätzlich jederzeit sofort manuell gestartet werden.

- $cronFetch, - 'check_sla' => $cronSla, - 'check_escalations' => $cronEscalations, - 'process_recurring' => $cronRecurring, - 'process_backups' => $cronBackups, - 'anonymize_data' => $cronAnonymize, - 'auto_close_tickets'=> $cronAutoClose, - 'process_snoozes' => $cronSnoozes, - 'remind_customers' => $cronRemind, - ]; - ?> - - - $jobMeta): ?> - - - - - - - -
-
- - - - -
-
-
-
-

Echter Server-Cronjob

- - -

- ✅ Dieser Server unterstützt eine automatische Installation (Shell-Zugriff erkannt). - DeskHub trägt dafür einen klar markierten Block in die Crontab des Server-Benutzers ein – - alle sonstigen vorhandenen Einträge bleiben unangetastet. -

-

- Status: - - ● Installiert - - ○ Nicht installiert - -

-
-
- - - -
- -
- - - -
- -
- -

- ❌ Auf diesem Server nicht möglich:
- Typisch bei Shared-Hosting (z.B. den meisten Strato-Webhosting-Paketen). Der oben aktive - automatische Hintergrund-Betrieb deckt das in der Regel bereits ab – alternativ unten die - Zeilen manuell im Hosting-Kundenlogin eintragen, falls dort ein Cronjob-Manager verfügbar ist. -

- -
- Crontab-Zeilen anzeigen -
-
-
-
- - - + ['label' => 'E-Mail-Abruf', 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'], + 'check_sla' => ['label' => 'SLA-Prüfung', 'file' => 'check_sla.php', 'fn' => 'run_check_sla'], + 'check_escalations' => ['label' => 'Eskalationsregeln', 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'], + 'process_recurring' => ['label' => 'Wiederkehrende Tickets', 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'], + 'process_backups' => ['label' => 'Automatische Backups', 'file' => 'process_backups.php', 'fn' => 'run_process_backups'], + 'anonymize_data' => ['label' => 'Datenanonymisierung', 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'], + 'auto_close_tickets'=> ['label' => 'Automatisches Schließen', 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'], + 'process_snoozes' => ['label' => 'Wiedervorlagen', 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'], + 'remind_customers' => ['label' => 'Kundenerinnerung', 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'], + 'send_csat' => ['label' => 'Zufriedenheitsabfrage', 'file' => 'send_csat.php', 'fn' => 'run_send_csat'], + 'run_plugins' => ['label' => 'Plugins (Hintergrundaufgaben)', 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'], +]; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + + if ($action === 'save_system') { + $status = ($_POST['helpdesk_status'] ?? 'online') === 'offline' ? 'offline' : 'online'; + $offlineMsg = trim($_POST['offline_message'] ?? ''); + $defaultDept = (int)($_POST['default_department_id'] ?? 0); + $kbEnabled = !empty($_POST['kb_enabled']) ? '1' : '0'; + if (!in_array($defaultDept, array_column($departments, 'id'))) { + $errors[] = 'Bitte eine gültige Standardabteilung wählen.'; + } else { + set_setting('helpdesk_status', $status); + set_setting('offline_message', $offlineMsg ?: 'Das Supportcenter ist derzeit nicht erreichbar.'); + set_setting('default_department_id', (string)$defaultDept); + set_setting('kb_enabled', $kbEnabled); + set_setting('ticket_ratings_enabled', !empty($_POST['ticket_ratings_enabled']) ? '1' : '0'); + set_setting('auto_time_tracking_enabled', !empty($_POST['auto_time_tracking_enabled']) ? '1' : '0'); + set_setting('auto_assign_on_open', !empty($_POST['auto_assign_on_open']) ? '1' : '0'); + set_setting('live_chat_enabled', !empty($_POST['live_chat_enabled']) ? '1' : '0'); + $liveChatDept = $_POST['live_chat_department_id'] ?? ''; + set_setting('live_chat_department_id', ($liveChatDept !== '' && in_array((int)$liveChatDept, array_column($departments, 'id'))) ? (string)(int)$liveChatDept : ''); + set_setting('live_chat_send_transcript', !empty($_POST['live_chat_send_transcript']) ? '1' : '0'); + set_setting('auto_close_enabled', !empty($_POST['auto_close_enabled']) ? '1' : '0'); + $autoCloseDays = max(1, min(90, (int) ($_POST['auto_close_days'] ?? 7))); + set_setting('auto_close_days', (string) $autoCloseDays); + set_setting('remind_enabled', !empty($_POST['remind_enabled']) ? '1' : '0'); + set_setting('remind_after_days', (string) max(1, min(60, (int) ($_POST['remind_after_days'] ?? 3)))); + csat_set_enabled(!empty($_POST['csat_enabled']), (int) ($_POST['csat_delay_minutes'] ?? 30)); + $timeout = max(5, min(480, (int)($_POST['staff_session_timeout'] ?? 30))); + set_setting('staff_session_timeout', (string)$timeout); + audit_log($staff['id'], "Systemeinstellungen geändert"); + flash('success', 'Systemeinstellungen gespeichert.'); + redirect('/staff/settings.php?tab=allgemein'); + } + } + + if ($action === 'save_privacy') { + $enabled = !empty($_POST['retention_enabled']) ? '1' : '0'; + $months = max(1, min(120, (int)($_POST['retention_months'] ?? 24))); + $ipAnon = !empty($_POST['ip_anonymize']) ? '1' : '0'; + set_setting('retention_enabled', $enabled); + set_setting('retention_months', (string)$months); + set_setting('ip_anonymize', $ipAnon); + audit_log($staff['id'], 'Datenschutzeinstellungen geändert'); + flash('success', 'Datenschutzeinstellungen gespeichert.'); + redirect('/staff/settings.php?tab=datenschutz'); + } + + if ($action === 'run_anonymize_now') { + define('DESKHUB_CRON_RUNNER', true); + require_once __DIR__ . '/../cron/anonymize_data.php'; + $result = run_anonymization(get_db(), true); + audit_log($staff['id'], "Datenanonymisierung manuell ausgeführt: {$result['count']} Ticket(s)"); + flash('success', "{$result['count']} Ticket(s) anonymisiert."); + redirect('/staff/settings.php?tab=datenschutz'); + } + + // Manueller Start eines Cronjobs aus dem Backend ("Jetzt ausführen"-Button, Tab Cron-Jobs). + // Führt exakt die gleiche Logik aus wie der echte Cronjob, nur direkt per Klick statt per crontab. + if ($action === 'run_cron_now') { + $job = $_POST['job'] ?? ''; + if (!isset($cronJobs[$job])) { + flash('error', 'Unbekannter Cronjob.'); + redirect('/staff/settings.php?tab=cron'); + } + $meta = $cronJobs[$job]; + define('DESKHUB_CRON_RUNNER', true); + require_once __DIR__ . '/../cron/' . $meta['file']; + $fn = $meta['fn']; + + try { + if ($job === 'anonymize_data') { + $result = $fn(get_db(), true); + $summary = "{$result['count']} Ticket(s) anonymisiert."; + } elseif ($job === 'process_backups') { + // Manueller Klick erzwingt ein Backup unabhängig vom Zeitplan. + $result = $fn(get_db(), true, true); + $summary = $result['ok'] ? ($result['ran'] ? 'Backup erstellt.' : 'Kein Backup nötig.') : 'Backup fehlgeschlagen.'; + } else { + $result = $fn(get_db(), true); + $summary = end($result['log']) ?: 'Ausgeführt.'; + } + audit_log($staff['id'], "Cronjob manuell ausgeführt: {$meta['label']} – {$summary}"); + flash('success', "{$meta['label']}: {$summary}"); + } catch (Throwable $e) { + audit_log($staff['id'], "Cronjob manuell fehlgeschlagen: {$meta['label']} – " . $e->getMessage()); + flash('error', "{$meta['label']} fehlgeschlagen: " . $e->getMessage()); + } + redirect('/staff/settings.php?tab=cron'); + } + + // Ein-/Ausschalten des eingebauten Auto-Cron (siehe includes/pseudo_cron.php). + if ($action === 'save_pseudo_cron') { + $enabled = !empty($_POST['pseudo_cron_enabled']) ? '1' : '0'; + set_setting('pseudo_cron_enabled', $enabled); + audit_log($staff['id'], $enabled === '1' ? 'Automatischer Hintergrund-Cron aktiviert' : 'Automatischer Hintergrund-Cron deaktiviert'); + flash('success', 'Gespeichert.'); + redirect('/staff/settings.php?tab=cron'); + } + + // Echte System-Crontab installieren/entfernen (nur auf Servern mit Shell-Zugriff möglich, + // siehe includes/crontab_installer.php – schreibt ausschließlich feste, fest hinterlegte Zeilen). + if ($action === 'install_crontab') { + $result = crontab_install(); + if ($result['ok']) { + audit_log($staff['id'], 'Server-Crontab installiert/aktualisiert (6 DeskHub-Cronjobs)'); + flash('success', 'Crontab installiert. Die 6 Cronjobs laufen jetzt direkt über den Server-Scheduler.'); + } else { + audit_log($staff['id'], 'Server-Crontab-Installation fehlgeschlagen: ' . $result['error']); + flash('error', 'Installation fehlgeschlagen: ' . $result['error']); + } + redirect('/staff/settings.php?tab=cron'); + } + + if ($action === 'remove_crontab') { + $result = crontab_uninstall(); + if ($result['ok']) { + audit_log($staff['id'], 'Server-Crontab-Eintrag von DeskHub entfernt'); + flash('success', 'Crontab-Eintrag entfernt.'); + } else { + audit_log($staff['id'], 'Entfernen der Server-Crontab fehlgeschlagen: ' . $result['error']); + flash('error', 'Entfernen fehlgeschlagen: ' . $result['error']); + } + redirect('/staff/settings.php?tab=cron'); + } + + if ($action === 'save_name') { + $name = trim($_POST['display_name'] ?? ''); + $alertTo = trim((string) ($_POST['error_alert_email'] ?? '')); + foreach (preg_split('/[,;\s]+/', $alertTo, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $m) { + if (!filter_var($m, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Die Adresse für Fehler-Mails ist ungültig: ' . $m; break; } + } + if (!$name) { $errors[] = 'Bitte einen Anzeigenamen angeben.'; } + elseif (!$errors) { + set_setting('display_name', $name); + $siteUrl = rtrim(trim($_POST['site_url'] ?? ''), '/'); + if ($siteUrl) set_setting('site_url', $siteUrl); + $tz = trim($_POST['timezone'] ?? ''); + if ($tz && in_array($tz, timezone_identifiers_list(), true)) set_setting('timezone', $tz); + set_setting('mail_enabled', !empty($_POST['mail_enabled']) ? '1' : '0'); + set_setting('error_alert', !empty($_POST['error_alert']) ? '1' : '0'); + set_setting('error_alert_email', mb_substr($alertTo, 0, 300)); + audit_log($staff['id'], "Anzeigename / Basis-Einstellungen geändert: $name"); + flash('success', 'Einstellungen gespeichert.'); + redirect('/staff/settings.php?tab=allgemein'); + } + } + + if ($action === 'upload_logo') { + $file = $_FILES['logo'] ?? []; + $allowed = ['png','jpg','jpeg','gif','svg']; + $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); + if (empty($file['name'])) { $errors[] = 'Bitte eine Bilddatei auswählen.'; } + elseif ($file['error'] !== UPLOAD_ERR_OK) { $errors[] = 'Upload fehlgeschlagen.'; } + elseif ($file['size'] > 2*1024*1024) { $errors[] = 'Max. 2 MB.'; } + elseif (!in_array($ext, $allowed, true)) { $errors[] = 'Erlaubt: PNG, JPG, GIF, SVG.'; } + else { + foreach ($allowed as $e2) @unlink($branding.'/logo.'.$e2); + $dest = $branding.'/logo.'.$ext; + if (!move_uploaded_file($file['tmp_name'], $dest)) { $errors[] = 'Speichern fehlgeschlagen.'; } + else { set_setting('logo_path', '/assets/branding/logo.'.$ext.'?v='.time()); audit_log($staff['id'],'Logo aktualisiert'); flash('success','Logo aktualisiert.'); redirect('/staff/settings.php?tab=design'); } + } + } + if ($action === 'upload_favicon') { + $file = $_FILES['favicon'] ?? []; + $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); + $err = null; + if (empty($file['name'])) $err = 'Bitte eine Datei auswählen.'; + elseif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) $err = 'Upload fehlgeschlagen' . (($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE ? ' (die Datei ist größer als das Upload-Limit des Servers).' : '.'); + elseif ($file['size'] > 1024 * 1024) $err = 'Das Favicon darf höchstens 1 MB groß sein.'; + elseif (!isset(favicon_types()[$ext])) $err = 'Erlaubt: ICO, PNG, JPG, GIF, WebP, SVG.'; + else $err = favicon_validate_file($file['tmp_name'], $ext); + if ($err !== null) { $errors[] = $err; } + else { + foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); + if (!move_uploaded_file($file['tmp_name'], $branding . '/favicon.' . $ext)) { $errors[] = 'Speichern fehlgeschlagen (Ordner assets/branding/ nicht beschreibbar?).'; } + else { set_setting('favicon_path', '/assets/branding/favicon.' . $ext . '?v=' . time()); audit_log($staff['id'], 'Favicon aktualisiert'); flash('success', 'Favicon aktualisiert. Browser zeigen Änderungen oft erst nach Neuladen (Strg+F5) oder in einem neuen Tab.'); redirect('/staff/settings.php?tab=design'); } + } + } + if ($action === 'remove_favicon') { + foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); + set_setting('favicon_path', null); audit_log($staff['id'], 'Favicon entfernt'); flash('success', 'Favicon entfernt.'); redirect('/staff/settings.php?tab=design'); + } + if ($action === 'save_logo_options') { + $hide = !empty($_POST['logo_hide_name']) ? '1' : '0'; + set_setting('logo_hide_name', $hide); + audit_log($staff['id'], $hide === '1' ? 'Name neben dem Logo ausgeblendet' : 'Name neben dem Logo wird angezeigt'); + flash('success', $hide === '1' ? 'Der Name wird neben dem Logo nicht mehr angezeigt.' : 'Der Name wird wieder neben dem Logo angezeigt.'); + redirect('/staff/settings.php?tab=design'); + } + if ($action === 'remove_logo') { + foreach (['png','jpg','jpeg','gif','svg'] as $e2) @unlink($branding.'/logo.'.$e2); + set_setting('logo_path', null); audit_log($staff['id'],'Logo entfernt'); flash('success','Logo entfernt.'); redirect('/staff/settings.php?tab=design'); + } + if ($action === 'upload_banner') { + $file = $_FILES['banner'] ?? []; + $allowed = ['png','jpg','jpeg','gif']; + $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); + if (empty($file['name'])) { $errors[] = 'Bitte eine Bilddatei auswählen.'; } + elseif ($file['error'] !== UPLOAD_ERR_OK) { $errors[] = 'Upload fehlgeschlagen.'; } + elseif ($file['size'] > 5*1024*1024) { $errors[] = 'Max. 5 MB.'; } + elseif (!in_array($ext, $allowed, true)) { $errors[] = 'Erlaubt: PNG, JPG, GIF.'; } + else { + foreach ($allowed as $e2) @unlink($branding.'/banner.'.$e2); + $dest = $branding.'/banner.'.$ext; + if (!move_uploaded_file($file['tmp_name'], $dest)) { $errors[] = 'Speichern fehlgeschlagen.'; } + else { set_setting('banner_path', '/assets/branding/banner.'.$ext.'?v='.time()); audit_log($staff['id'],'Titelbild aktualisiert'); flash('success','Titelbild aktualisiert.'); redirect('/staff/settings.php?tab=design'); } + } + } + if ($action === 'remove_banner') { + foreach (['png','jpg','jpeg','gif'] as $e2) @unlink($branding.'/banner.'.$e2); + set_setting('banner_path', null); audit_log($staff['id'],'Titelbild entfernt'); flash('success','Titelbild entfernt.'); redirect('/staff/settings.php?tab=design'); + } + if ($action === 'save_homepage_content') { + $popularArticlesEnabled = !empty($_POST['homepage_popular_articles_enabled']) ? '1' : '0'; + set_setting('homepage_popular_articles_enabled', $popularArticlesEnabled); + audit_log( + $staff['id'], + $popularArticlesEnabled === '1' + ? 'Beliebte Artikel auf der Startseite eingeblendet' + : 'Beliebte Artikel auf der Startseite ausgeblendet' + ); + flash('success', 'Startseiten-Inhalte gespeichert.'); + redirect('/staff/settings.php?tab=design'); + } + if ($action === 'upload_custom_theme') { + try { + $theme = install_custom_theme_upload($_FILES['theme_zip'] ?? []); + $actionLabel = $theme['replaced'] ? 'aktualisiert' : 'hochgeladen'; + audit_log($staff['id'], "Kundenportal-Theme {$actionLabel}: {$theme['name']}"); + flash('success', "Theme „{$theme['name']}“ wurde {$actionLabel}."); + redirect('/staff/settings.php?tab=design'); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + if ($action === 'save_theme_catalog' && plugin_strict_mode()) { + $errors[] = 'Im geprüften Modus ist der Katalog fest vorgegeben.'; + } elseif ($action === 'save_theme_catalog') { + $url = trim((string) ($_POST['theme_catalog_url'] ?? '')); + if ($url !== '' && ($urlError = plugin_url_check($url))) { + $errors[] = 'Katalog-Adresse: ' . $urlError; + } else { + set_setting('theme_catalog_url', $url); + flash('success', 'Katalog-Adresse gespeichert.'); + redirect('/staff/settings.php?tab=design'); + } + } + if ($action === 'theme_catalog_install') { + $res = theme_catalog_install((string) ($_POST['slug'] ?? '')); + if ($res['ok']) { + audit_log($staff['id'], "Kundenportal-Theme aus dem Katalog installiert: {$res['theme']['name']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')'); + flash('success', "Theme „{$res['theme']['name']}“ installiert (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.'); + redirect('/staff/settings.php?tab=design'); + } + $errors[] = $res['error']; + } + if ($action === 'activate_custom_theme') { + $themeId = (string) ($_POST['theme_id'] ?? ''); + $theme = null; + foreach (list_custom_themes() as $candidate) { + if ($candidate['id'] === $themeId) { + $theme = $candidate; + break; + } + } + if ($theme === null) { + $errors[] = 'Das ausgewählte Theme wurde nicht gefunden.'; + } else { + set_setting('active_custom_theme', $themeId); + audit_log($staff['id'], "Kundenportal-Theme aktiviert: {$theme['name']}"); + flash('success', "Theme „{$theme['name']}“ ist jetzt aktiv."); + redirect('/staff/settings.php?tab=design'); + } + } + if ($action === 'deactivate_custom_theme') { + set_setting('active_custom_theme', null); + audit_log($staff['id'], 'Benutzerdefiniertes Kundenportal-Theme deaktiviert'); + flash('success', 'Das Standarddesign ist jetzt aktiv.'); + redirect('/staff/settings.php?tab=design'); + } + if ($action === 'delete_custom_theme') { + $themeId = (string) ($_POST['theme_id'] ?? ''); + $theme = null; + foreach (list_custom_themes() as $candidate) { + if ($candidate['id'] === $themeId) { + $theme = $candidate; + break; + } + } + if ($theme === null || !delete_custom_theme($themeId)) { + $errors[] = 'Das Theme konnte nicht gelöscht werden.'; + } else { + if (get_setting('active_custom_theme', '') === $themeId) { + set_setting('active_custom_theme', null); + } + audit_log($staff['id'], "Kundenportal-Theme gelöscht: {$theme['name']}"); + flash('success', "Theme „{$theme['name']}“ wurde gelöscht."); + redirect('/staff/settings.php?tab=design'); + } + } + + if ($action === 'save_uploads') { + $dangerous = ['php','php3','php4','php5','phtml','phar','exe','bat','cmd','sh','pl','py','asp','aspx','jsp','cgi']; + $extList = array_filter(array_map('trim', explode(',', strtolower($_POST['upload_allowed_extensions'] ?? '')))); + $extList = array_diff($extList, $dangerous); + set_setting('upload_allowed_extensions', implode(', ', $extList)); + set_setting('upload_max_size_mb', max(1, min(100, (int)($_POST['upload_max_size_mb'] ?? 10)))); + audit_log($staff['id'], 'Upload-Einstellungen geändert'); + flash('success', 'Upload-Einstellungen gespeichert.'); + redirect('/staff/settings.php?tab=uploads'); + } + + if ($action === 'save_footer_pages') { + foreach (['footer_privacy_label','footer_privacy_url','footer_privacy_html','footer_imprint_label','footer_imprint_url','footer_imprint_html'] as $k) + set_setting($k, trim($_POST[$k] ?? '') ?: null); + audit_log($staff['id'], 'Footer-Seiten geändert'); + flash('success', 'Footer-Einstellungen gespeichert.'); + redirect('/staff/settings.php?tab=footer'); + } + + if ($action === 'save_notifications') { + $discordNew = trim($_POST['discord_webhook_url'] ?? ''); + $tgTokenNew = trim($_POST['telegram_bot_token'] ?? ''); + $tgChatId = trim($_POST['telegram_chat_id'] ?? ''); + // Leer lassen = unverändert; Wert "-" = explizit löschen; sonst verschlüsseln + if ($discordNew !== '') { + set_setting('discord_webhook_url', $discordNew === '-' ? null : encrypt_secret($discordNew)); + } + if ($tgTokenNew !== '') { + set_setting('telegram_bot_token', $tgTokenNew === '-' ? null : encrypt_secret($tgTokenNew)); + } + if ($tgChatId !== '') { + set_setting('telegram_chat_id', $tgChatId === '-' ? null : $tgChatId); + } + audit_log($staff['id'], 'Benachrichtigungen geändert'); + flash('success', 'Benachrichtigungseinstellungen gespeichert.'); + redirect('/staff/settings.php?tab=benachrichtigungen'); + } + // Push-Benachrichtigungen für die Android-App (Firebase Cloud Messaging) + if ($action === 'save_fcm') { + set_setting('fcm_enabled', !empty($_POST['fcm_enabled']) ? '1' : '0'); + set_setting('fcm_show_details', !empty($_POST['fcm_show_details']) ? '1' : '0'); + set_setting('push_brand', fcm_clean_brand((string) ($_POST['push_brand'] ?? ''))); + flash('success', 'Einstellungen für die Android-Benachrichtigungen gespeichert.'); + redirect('/staff/settings.php?tab=benachrichtigungen'); + } + if ($action === 'remove_fcm') { + set_setting('fcm_service_account', ''); + set_setting('fcm_access_token', ''); + audit_log($staff['id'], 'Firebase-Dienstkonto für Android-Push entfernt'); + flash('success', 'Der Firebase-Schlüssel wurde vom Server entfernt.'); + redirect('/staff/settings.php?tab=benachrichtigungen'); + } + if ($action === 'test_fcm') { + if (fcm_mode() === 'none') { flash('error', 'Push ist nicht eingerichtet: Es ist kein Push-Dienst eingetragen und kein eigener Firebase-Schlüssel hinterlegt.'); redirect('/staff/settings.php?tab=benachrichtigungen'); } + $t = fcm_send_test($pdo, (int) $staff['id']); + if ($t['devices'] === 0) { flash('error', 'Auf deinem Konto ist noch kein Android-Gerät angemeldet. Melde dich in der App an (Benachrichtigungen erlauben) und versuche es erneut.'); redirect('/staff/settings.php?tab=benachrichtigungen'); } + $t['sent'] > 0 ? flash('success', "Testnachricht an {$t['sent']} Gerät(e) gesendet. Sie sollte jetzt auf dem Handy erscheinen.") : flash('error', 'Senden fehlgeschlagen: ' . ($t['error'] !== '' ? $t['error'] : 'unbekannter Fehler')); + redirect('/staff/settings.php?tab=benachrichtigungen'); + } + + if ($action === 'test_notifications') { + $sent = []; + if (discord_notify('🔔 Testnachricht von DeskHub')) $sent[] = 'Discord'; + if (telegram_notify('🔔 Testnachricht von DeskHub')) $sent[] = 'Telegram'; + $sent ? flash('success', 'Gesendet an: '.implode(', ', $sent)) : flash('error', 'Kein Kanal konfiguriert oder Fehler.'); + redirect('/staff/settings.php?tab=benachrichtigungen'); + } + + if ($action === 'save_smtp') { + set_setting('smtp_host', trim($_POST['smtp_host'] ?? '') ?: null); + set_setting('smtp_port', trim($_POST['smtp_port'] ?? '') ?: null); + set_setting('smtp_encryption', $_POST['smtp_encryption'] ?? 'tls'); + set_setting('smtp_username', trim($_POST['smtp_username'] ?? '') ?: null); + if (trim($_POST['smtp_password'] ?? '') !== '') set_setting('smtp_password', encrypt_secret(trim($_POST['smtp_password']))); + set_setting('smtp_from_name', trim($_POST['smtp_from_name'] ?? '') ?: null); + set_setting('smtp_from_email', trim($_POST['smtp_from_email'] ?? '') ?: null); + audit_log($staff['id'], 'SMTP geändert'); + flash('success', 'SMTP-Einstellungen gespeichert.'); + redirect('/staff/settings.php?tab=email'); + } + if ($action === 'test_smtp') { + $testBody = email_wrap(email_p('Diese Test-E-Mail bestätigt, dass Ihr SMTP-Versand korrekt eingerichtet ist. 🎉')); + $ok = send_mail($staff['email'], 'DeskHub – Test-E-Mail', $testBody); + $ok ? flash('success', 'Test-Mail an '.$staff['email'].' gesendet.') : flash('error', 'Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. '.smtp_last_error()); + redirect('/staff/settings.php?tab=email'); + } + // Einzelnes globales IMAP-Konto wurde durch die Tabelle imap_accounts abgelöst + // (mehrere Postfächer möglich, siehe staff/imap_accounts.php). Die alten + // Einzel-Settings (imap_host/imap_user/...) werden nur noch einmalig per + // Migration php_0011_imap_accounts als erstes Postfach übernommen. +} + +// ── Lazy-Encryption: Klartext-Credentials beim ersten Admin-Besuch sichern ── +// Direkt über PDO (nicht get_setting) um den statischen Cache zu umgehen. +// Idempotent: AES-GCM-Blobs (base64, min. 28 Bytes) werden nicht erneut verschlüsselt. +foreach (['discord_webhook_url', 'telegram_bot_token', 'smtp_password', 'imap_pass'] as $_lek) { + $stmt_le = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); + $stmt_le->execute([$_lek]); + $raw_le = $stmt_le->fetchColumn(); + if ($raw_le !== false && $raw_le !== '' && $raw_le !== null) { + $decoded_le = base64_decode($raw_le, true); + if ($decoded_le === false || strlen($decoded_le) < 28) { + // Klartext gefunden → jetzt verschlüsseln + $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ?') + ->execute([encrypt_secret($raw_le), $_lek]); + } + } +} +unset($_lek, $stmt_le, $raw_le, $decoded_le); + +// ── Werte laden ─────────────────────────────────────────────────────────────── +$currentLogo = get_setting('logo_path'); +$currentBanner = get_setting('banner_path'); +$homepagePopularArticlesEnabled = get_setting('homepage_popular_articles_enabled', '1') === '1'; +$currentName = get_setting('display_name', config()['site']['name']); +$helpdeskStatus = get_setting('helpdesk_status', 'online'); +$offlineMessage = get_setting('offline_message', 'Das Supportcenter ist derzeit nicht erreichbar.'); +$defaultDeptId = get_setting('default_department_id', (string)($departments[0]['id'] ?? '')); +$kbEnabled = get_setting('kb_enabled', '1') === '1'; +$discordUrl = get_setting('discord_webhook_url', ''); +$telegramToken = get_setting('telegram_bot_token', ''); +$telegramChatId = get_setting('telegram_chat_id', ''); +$smtpHost = get_setting('smtp_host', ''); +$smtpPort = get_setting('smtp_port', '587'); +$smtpEncryption = get_setting('smtp_encryption', 'tls'); +$smtpUsername = get_setting('smtp_username', ''); +$smtpFromName = get_setting('smtp_from_name', ''); +$smtpFromEmail = get_setting('smtp_from_email', ''); +$footerPrivacyLabel = get_setting('footer_privacy_label', 'Datenschutz'); +$footerPrivacyUrl = get_setting('footer_privacy_url', ''); +$footerPrivacyHtml = get_setting('footer_privacy_html', ''); +$footerImprintLabel = get_setting('footer_imprint_label', 'Impressum'); +$footerImprintUrl = get_setting('footer_imprint_url', ''); +$footerImprintHtml = get_setting('footer_imprint_html', ''); +$uploadExtensions = get_setting('upload_allowed_extensions', implode(', ', config()['uploads']['allowed_extensions'])); +$uploadMaxSize = get_setting('upload_max_size_mb', '10'); +$customThemes = list_custom_themes(); +$activeCustomTheme = get_setting('active_custom_theme', ''); + +$cronFetch = cron_last_run_label(get_setting('cron_fetch_emails_last_run')); +$cronSla = cron_last_run_label(get_setting('cron_check_sla_last_run')); +$cronRecurring = cron_last_run_label(get_setting('cron_process_recurring_last_run')); +$cronBackups = cron_last_run_label(get_setting('cron_process_backups_last_run')); +$cronEscalations = cron_last_run_label(get_setting('cron_check_escalations_last_run')); +$cronAnonymize = cron_last_run_label(get_setting('cron_anonymize_data_last_run')); +$cronAutoClose = cron_last_run_label(get_setting('cron_auto_close_tickets_last_run')); +$cronSnoozes = cron_last_run_label(get_setting('cron_process_snoozes_last_run')); +$cronRemind = cron_last_run_label(get_setting('cron_remind_customers_last_run')); +$cronPlugins = cron_last_run_label(get_setting('cron_run_plugins_last_run')); +$cronCsat = cron_last_run_label(get_setting('cron_send_csat_last_run')); + + +$tab = $_GET['tab'] ?? 'allgemein'; +$pageTitle = 'Einstellungen'; +$activePage = 'settings'; +require __DIR__ . '/_header.php'; +?> + +
+

Einstellungen

+ 📥 osTicket Import +
+ +
+ + +
+ 'Allgemein', + 'design' => 'Design', + 'email' => 'E-Mail', + 'benachrichtigungen' => 'Benachrichtigungen', + 'uploads' => 'Uploads', + 'footer' => 'Footer', + 'cron' => 'Cron-Jobs', + 'datenschutz' => '🔒 Datenschutz', + ]; + foreach ($tabs as $key => $label): + $active = $tab === $key; + ?> + + + + +
+ + + + +
+ +
+

System

+
+ + + + + + + + + + + + + + +
+
+ + Tage +
+ + +
+
+ + Tage (am besten kürzer als die Frist zum automatischen Schließen) +
+ + +
+
+ + Minuten (so bleibt Zeit, ein versehentlich geschlossenes Ticket wieder zu öffnen). Gilt nur für Tickets, die ab dem Einschalten geschlossen werden. Die Auswertung steht unter Übersicht. +
+ + +
+ + + +
+ +
+ +
+ + Minuten (5–480). Countdown wird oben rechts angezeigt. +
+
+ +
+
+
+ +
+

Anzeigename & URL

+

Wird im Browser-Tab, in E-Mails und in der Web-App angezeigt.

+
+ + + + + + +

Wird in E-Mail-Links verwendet. Kein abschließendes /

+ + + + + +

Gemeldet werden Fehler, bei denen eine Seite nicht angezeigt werden kann. Dieselbe Meldung kommt höchstens einmal pro Stunde, insgesamt höchstens 10 Mails pro Stunde. Voraussetzung ist ein funktionierender E-Mail-Versand und eine erreichbare Datenbank.

+
+
+
+ +
+ + + +
+ +
+

Firmenlogo

+

PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. Der Name der Installation steht standardmäßig daneben und lässt sich unten ausblenden.

+ +
+ Logo +

+
+ + +
+
+ + +
+
+ +

Kein Logo hinterlegt.

+ +
+ + +
+
+
+ +
+

Favicon (Symbol im Browser-Tab)

+

ICO, PNG, JPG, GIF, WebP oder SVG, höchstens 1 MB, am besten quadratisch (z. B. 256×256). Ohne Favicon verwendet DeskHub das Logo.

+ + +
+ + Favicon + +
+ + +
+
+ +

Kein eigenes Favicon hinterlegt.

+ +
+ + +
+
+
+ +
+

Titelbild (Hero-Banner)

+

Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.

+ + Banner +
+ + +
+ +

Kein Titelbild hinterlegt.

+ +
+ + +
+
+
+ +
+ +
+

Startseiten-Inhalte

+

+ Legen Sie fest, welche zusätzlichen Bereiche auf der Kundenportal-Startseite angezeigt werden. +

+
+ + +
+
+
+ +
+

Eigene Kundenportal-Themes

+

+ Laden Sie mehrere Designs als ZIP hoch und aktivieren Sie eines davon global für alle Kunden. + Das ZIP muss im Stammverzeichnis eine theme.css enthalten. Zulässig sind CSS, + PNG, JPG, GIF, WebP, WOFF und WOFF2; maximal 10 MB. + Autor und Version können im CSS-Kopf mit Author: und Version: angegeben werden. +

+ +
+ +
+ + +
+ +
+

+ Der Anzeigename wird automatisch aus dem ZIP-Dateinamen übernommen, zum Beispiel + Classic-Support-Blue.zip → Classic Support Blue. + Ein erneuter Upload mit demselben Namen ersetzt das vorhandene Theme. +

+ + +

Noch keine eigenen Themes hochgeladen.

+ +
+ + + +
+
+ · Version + + Aktiv + +
Von
+
+
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+
+
+ +
+ +
+ +
+

Themes aus dem Katalog

+

Fertige Designs direkt installieren. Prüfsumme und Signatur werden vor der Installation geprüft.

+ +

Katalog aktualisieren

+ +
+ +
+
+ + Katalog aktualisieren +
+ + + +
+ + + + + + + + + + + + + + + +
ThemeVersionSignatur
+
von
installiert:
✓ Signiert' : 'Nicht signiert' ?> + ✓ Installiert + +
+
Der Katalog enthält keine Themes.
+ + +
+ + + +
+ +
+

SMTP-Versand

+
+ + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+
+ +
+

E-Mail-Abruf (IMAP)

+

Postfächer, aus denen eingehende E-Mails als Tickets importiert werden. Es können mehrere Postfächer angelegt werden (z.B. ein eigenes je Abteilung) – jedes mit eigenem Server, Zugangsdaten und optionaler Standardabteilung.

+ query('SELECT COUNT(*) FROM imap_accounts WHERE is_active = 1')->fetchColumn(); ?> +

+ 0): ?> + ● aktive(s) Postfach/Postfächer eingerichtet + + ○ Noch kein Postfach eingerichtet + + · Letzter Abruf: +

+ Postfächer verwalten → +
+ +
+ + + +
+
+

Discord & Telegram

+

Mitarbeiter werden bei neuen Tickets und Antworten benachrichtigt. Ticket-Daten werden dabei an den jeweiligen Drittanbieter übermittelt.

+

+ 🔒 Webhook-URL und Bot-Token werden verschlüsselt gespeichert. Leer lassen = unverändert. Wert - löscht ihn. +

+
+ + + +

Kanal-Einstellungen → Integrationen → Webhooks → Neuer Webhook

+ + + + +

Bot über @BotFather erstellen, Chat-ID via @userinfobot ermitteln.

+
+ +
+
+
+ + +
+
+ +query('SELECT COUNT(*) FROM push_devices')->fetchColumn(); + $__fcmErr = (string) get_setting('fcm_last_error', ''); + $__fcmOk = (string) get_setting('fcm_last_ok', ''); + $__fcmFromConfig = (string) (config()['fcm']['service_account_file'] ?? '') !== ''; +?> +
+

📱 Android-App: Push-Benachrichtigungen

+

Popups auf dem Handy bei neuen Tickets, Kundenantworten und Zuweisungen, auch wenn die App geschlossen ist. Die Nachrichten laufen über Google (Firebase Cloud Messaging).

+

+ + ✓ Eigenes Firebase-Projekt ·
+ + ✓ Push ist aktiv · über den DeskHub-Push-Dienst, es ist nichts einzurichten
+ + Nicht verfügbar. In dieser Version ist kein Push-Dienst eingetragen. Ihr könnt unten ein eigenes Firebase-Projekt verwenden.
+ + Gerät(e) angemeldet +
Letzter Fehler: +

+
+ + + + + +

Erscheint in jedem Popup neben dem App-Namen („DeskHub“). Den App-Namen selbst kann Android pro Server nicht ändern.

+
+
+
+
+
+
+
+
+ + +
+

Erlaubte Dateitypen

+

Gefährliche Typen (PHP, EXE, SH etc.) werden immer blockiert – unabhängig von dieser Liste.

+
+ + + +
+ + + +
+ +
+ MB +
+
+
+
+ + + +
+

Datenschutz & Impressum

+

Externer Link oder HTML-Inhalt (öffnet Popup). Ist ein Link gesetzt, wird das HTML-Feld ignoriert.

+
+ +
+
+
🔒 Datenschutzerklärung
+ + + + + + +
+
+
📄 Impressum
+ + + + + + +
+
+
+
+
+ + + +getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' + ? "SELECT COUNT(*) FROM tickets WHERE status IN ('closed','resolved') + AND updated_at < datetime('now', '-' || ? || ' months') + AND requester_email NOT LIKE 'anon_%'" + : "SELECT COUNT(*) FROM tickets WHERE status IN ('closed','resolved') + AND updated_at < DATE_SUB(NOW(), INTERVAL ? MONTH) + AND requester_email NOT LIKE 'anon_%'"; + $previewStmt = $pdo->prepare($previewSql); + $previewStmt->execute([$retMonths]); + $previewCount = (int)$previewStmt->fetchColumn(); +?> +
+ +
+

Automatische Datenlöschung

+

+ Geschlossene und gelöste Tickets werden nach der gewählten Zeit automatisch anonymisiert. + Name, E-Mail-Adresse und IP werden unkenntlich gemacht — das Ticket und seine Historie bleiben erhalten. +

+
+ + + + + + +
+ + Monate +
+ + + +
+ +
+
+
+ +
+

Status & Vorschau

+
+
+ + +
+

+ Aktuell würden Ticket(s) anonymisiert + (geschlossen/gelöst, älter als Monate). +

+
+ +

Was wird anonymisiert?

+
    +
  • Name → Anonymisiert
  • +
  • E-Mail → anon_[hash]@deleted.local
  • +
  • IP-Adresse → 0.0.0.0
  • +
  • Ticket-Nachrichten → bleiben erhalten
  • +
  • Ticket-Nummer, Betreff, Status → bleiben erhalten
  • +
+ +

Crontab-Eintrag

+
0 3 * * * php /pfad/zu/deskhub/cron/anonymize_data.php
+ +
+ + + +
+
+ +
+ + + + +
+

🔄 Automatischer Hintergrund-Betrieb (ohne Server-Cronjob)

+

+ Läuft komplett eingebaut, ganz ohne echten Server-Cronjob und ohne externen Dienst: + bei jeder Anmeldung/Nutzung im Backend und in der App wird im Hintergrund geprüft, + ob einer der Jobs unten fällig ist, und automatisch ausgeführt. Solange regelmäßig jemand + das Backend oder die App nutzt, laufen alle Jobs von selbst dauerhaft weiter – ganz ohne + Einrichtung bei Strato oder einem externen Anbieter. +

+
+
+ + + +
+ + Status: ● aktiv' : '○ deaktiviert' ?> + · Letzte automatische Prüfung: + +
+
+
+
+

Cron-Status

+

Jeder Job kann hier zusätzlich jederzeit sofort manuell gestartet werden.

+ $cronFetch, + 'check_sla' => $cronSla, + 'check_escalations' => $cronEscalations, + 'process_recurring' => $cronRecurring, + 'process_backups' => $cronBackups, + 'anonymize_data' => $cronAnonymize, + 'auto_close_tickets'=> $cronAutoClose, + 'process_snoozes' => $cronSnoozes, + 'remind_customers' => $cronRemind, + 'run_plugins' => $cronPlugins, + 'send_csat' => $cronCsat, + ]; + ?> + + + $jobMeta): ?> + + + + + + + +
+
+ + + + +
+
+
+
+

Echter Server-Cronjob

+ + +

+ ✅ Dieser Server unterstützt eine automatische Installation (Shell-Zugriff erkannt). + DeskHub trägt dafür einen klar markierten Block in die Crontab des Server-Benutzers ein – + alle sonstigen vorhandenen Einträge bleiben unangetastet. +

+

+ Status: + + ● Installiert + + ○ Nicht installiert + +

+
+
+ + + +
+ +
+ + + +
+ +
+ +

+ ❌ Auf diesem Server nicht möglich:
+ Typisch bei Shared-Hosting (z.B. den meisten Strato-Webhosting-Paketen). Der oben aktive + automatische Hintergrund-Betrieb deckt das in der Regel bereits ab – alternativ unten die + Zeilen manuell im Hosting-Kundenlogin eintragen, falls dort ein Cronjob-Manager verfügbar ist. +

+ +
+ Crontab-Zeilen anzeigen +
+
+
+
+ + + diff --git a/DeskHub-Webseite/staff/status_page.php b/DeskHub-Webseite/staff/status_page.php new file mode 100644 index 00000000..20b2c7fe --- /dev/null +++ b/DeskHub-Webseite/staff/status_page.php @@ -0,0 +1,110 @@ + +

Statusseite

+
+

Eine öffentliche Seite, auf der Ihre Kunden sehen, ob alles läuft, und über Störungen und Wartungen informiert werden. + Adresse: /status.php

+ +
+ +

Einstellungen

+ + + +
+
+ +
+

Dienste

+

Noch keine Dienste. Legen Sie unten zum Beispiel „Webseite“, „E-Mail“ oder „Telefon“ an.

+ +
+
+
+
+
+
+ +
+ + + +
+
+ +
+

Neue Meldung (Störung oder Wartung)

+ + +
+
+
+
+
+
+
+ +
+

Meldungen

+

Keine Meldungen in den letzten 60 Tagen.

+ +
+ + · · +
:
+ +
+ + +
+ +
+
+ +
+ diff --git a/DeskHub-Webseite/staff/ticket_create.php b/DeskHub-Webseite/staff/ticket_create.php index 2c1530ff..48f53788 100644 --- a/DeskHub-Webseite/staff/ticket_create.php +++ b/DeskHub-Webseite/staff/ticket_create.php @@ -149,6 +149,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } +// Ticket-Vorlagen: ?template=ID füllt das Formular vor +$ticketTemplates = []; +$activeTemplateId = 0; +try { + $ticketTemplates = ticket_templates_list($pdo); + if ($_SERVER['REQUEST_METHOD'] !== 'POST' && (int) ($_GET['template'] ?? 0) > 0) { + $tpl = ticket_template_get($pdo, (int) $_GET['template']); + if ($tpl) { + $activeTemplateId = (int) $tpl['id']; + $_POST['subject'] = (string) $tpl['subject']; + $_POST['message'] = nl2br(e((string) ($tpl['body'] ?? ''))); // der Editor erwartet HTML + if ($tpl['department_id']) $_POST['department_id'] = (string) $tpl['department_id']; + if ($tpl['help_topic_id']) $_POST['help_topic_id'] = (string) $tpl['help_topic_id']; + if ($tpl['priority_id']) $_POST['priority_id'] = (string) $tpl['priority_id']; + } + } +} catch (Throwable $e) { + $ticketTemplates = []; +} $pageTitle = 'Ticket erstellen'; $activePage = 'tickets'; require __DIR__ . '/_header.php'; @@ -161,6 +180,15 @@ require __DIR__ . '/_header.php'; ← Zur Ticket-Liste

Ticket im Namen eines Kunden anlegen – z.B. bei Telefonanrufen oder Vor-Ort-Anfragen.

+ +
+ Vorlage: + + + + Ohne Vorlage +
+
diff --git a/DeskHub-Webseite/staff/ticket_templates.php b/DeskHub-Webseite/staff/ticket_templates.php new file mode 100644 index 00000000..cf185785 --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_templates.php @@ -0,0 +1,84 @@ +query('SELECT id, name FROM departments ORDER BY name')->fetchAll(); +$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY id')->fetchAll(); +$helpTopics = help_topic_options($pdo); +$templates = ticket_templates_list($pdo, false, true); +$editing = null; +if (!empty($_GET['edit'])) { + foreach ($templates as $t) if ((int) $t['id'] === (int) $_GET['edit']) $editing = $t; +} +// Bei einem Fehler die eingegebenen Werte behalten +$v = $errors ? $_POST : ($editing ?: ['is_public' => 1, 'is_active' => 1, 'sort_order' => count($templates) * 10 + 10]); +$pageTitle = 'Ticket-Vorlagen'; +$activePage = 'ticket_templates'; +require __DIR__ . '/_header.php'; +?> +

Ticket-Vorlagen

+
+

Vorbelegte Formulare für häufige Anliegen, zum Beispiel „Neuer Mitarbeiter“ oder „Passwort zurücksetzen“. Kunden wählen sie auf der Seite „Neues Ticket“ (wenn sie für Kunden sichtbar sind), Mitarbeiter beim Anlegen eines Tickets im Web, im Windows-Programm und in der App.

+ +
+ +

+
+
+
+
+ + + + +
+
+
+
+
+
+ + + +
+
Abbrechen
+
+ +
+

Vorhandene Vorlagen

+

Noch keine Vorlagen.

+ +
+
+ · für Kunden' : ' · nur Mitarbeiter' ?> + · inaktiv' ?> +
+ Bearbeiten +
+
+ +
+ diff --git a/DeskHub-Webseite/staff/ticket_view.php b/DeskHub-Webseite/staff/ticket_view.php index 5d5dfce1..59ece48f 100644 --- a/DeskHub-Webseite/staff/ticket_view.php +++ b/DeskHub-Webseite/staff/ticket_view.php @@ -1268,6 +1268,21 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
+ + + +
+ Kundenzufriedenheit + +
von 5
+
„“
+
Bewertet am
+ +
Abfrage am gesendet, noch keine Antwort.
+ +
+ +
Wiedervorlage diff --git a/DeskHub-Webseite/tests/api_csat_status.php b/DeskHub-Webseite/tests/api_csat_status.php new file mode 100644 index 00000000..d43c8ec6 --- /dev/null +++ b/DeskHub-Webseite/tests/api_csat_status.php @@ -0,0 +1,166 @@ +getPathname(), strlen($root) + 1)); + if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; + if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } + @mkdir(dirname($tmp . '/' . $rel), 0755, true); + copy($f->getPathname(), $tmp . '/' . $rel); +} +@mkdir($tmp . '/uploads'); +@mkdir($tmp . '/config'); +file_put_contents($tmp . '/config/config.php', ' bin2hex(random_bytes(32)), + 'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'], + 'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'], + 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'], +], true) . ';'); + +putenv('DESKHUB_CONFIG_JSON'); +putenv('DESKHUB_SQLITE_PATH'); +putenv('DESKHUB_TESTING'); +putenv('DESKHUB_API_ALLOW_INSECURE=1'); +require_once $root . '/database/sqlite_schema.php'; +require_once $root . '/database/migrate.php'; +$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$db->exec('PRAGMA foreign_keys = ON'); +$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql'))); +$mig = run_migrations($db, true); +$now = date('Y-m-d H:i:s'); +$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$db->exec('DELETE FROM staff'); +$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); +$ids = []; +foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) { + $ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]); + $ids[$n] = (int) $db->lastInsertId(); +} +$db = null; + +$failures = []; +$total = 0; +$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; +$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp); + +try { + for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); } + $pdo = function () use ($tmp): PDO { + $p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); + $p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + return $p; + }; + $api = function (string $method, string $path, ?array $json = null, ?string $bearer = null) use ($webPort): array { + $ch = curl_init("http://127.0.0.1:$webPort$path"); + $h = ['Content-Type: application/json']; + if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer; + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h, CURLOPT_CUSTOMREQUEST => $method]); + if ($json !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json)); + $b = (string) curl_exec($ch); + $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + return [$s, json_decode($b, true) ?? $b]; + }; + $p = $pdo(); + $nowS = date('Y-m-d H:i:s'); + $p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + ->execute(['T9001', 'API-Test', 'Kunde', 'kunde@test.de', $dept, 2, 'open', 'web', $nowS, $nowS]); + $tid = (int) $p->lastInsertId(); + $login = function (string $email) use ($api): string { + [, $lg] = $api('POST', '/api/staff/v1/login.php', ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test']); + return (string) ($lg['token'] ?? ''); + }; + $admin = $login('chef@test.de'); + $agent = $login('agent@test.de'); + $check($admin !== '' && $agent !== '', 'Anmeldung von Administrator und Mitarbeiter'); + + // Cc-Empfänger + [$s, $r] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'add', 'email' => 'cc@test.de', 'name' => 'Cc Person'], $agent); + $check($s === 200 && count($r['collaborators']) === 1 && $r['collaborators'][0]['email'] === 'cc@test.de' && $r['collaborators'][0]['name'] === 'Cc Person', 'Cc hinzufügen'); + [$s, $r] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'add', 'email' => 'CC@test.de'], $agent); + $check(count($r['collaborators']) === 1, 'dieselbe Adresse nicht doppelt'); + [$s, ] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'add', 'email' => 'kaputt'], $agent); + $check($s === 422, 'ungültige Adresse wird abgelehnt'); + [$s, $r] = $api('GET', '/api/staff/v1/ticket_collaborators.php?number=T9001', null, $agent); + $cid = (int) ($r['collaborators'][0]['id'] ?? 0); + $check($s === 200 && $cid > 0, 'Cc-Liste abrufen'); + [$s, $r] = $api('GET', '/api/staff/v1/ticket.php?number=T9001', null, $agent); + $check(count($r['ticket']['collaborators']) === 1 && $r['ticket']['followers'] === [] && $r['ticket']['csat'] === null, 'Ticket enthält Cc, Beobachter und (noch keine) Bewertung'); + $api('PATCH', '/api/staff/v1/ticket_update.php', ['number' => 'T9001', 'following' => true], $agent); + [, $r] = $api('GET', '/api/staff/v1/ticket.php?number=T9001', null, $agent); + $check(count($r['ticket']['followers']) === 1 && $r['ticket']['followers'][0]['name'] === 'Agent', 'Beobachter erscheinen am Ticket'); + [, $r] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'remove', 'id' => $cid], $agent); + $check($r['collaborators'] === [], 'Cc entfernen'); + [$s, ] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'GIBTS-NICHT', 'action' => 'add', 'email' => 'a@b.de'], $agent); + $check($s === 404 || $s === 403, 'unbekanntes Ticket: ' . $s); + + // Bewertung am Ticket und Auswertung + $p->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, department_id, rating, comment, sent_at, answered_at) VALUES (?,?,?,?,?,?,?,?)') + ->execute([$tid, bin2hex(random_bytes(20)), $ids['Agent'], $dept, 5, 'Top', $nowS, $nowS]); + [, $r] = $api('GET', '/api/staff/v1/ticket.php?number=T9001', null, $agent); + $check($r['ticket']['csat']['rating'] === 5 && $r['ticket']['csat']['comment'] === 'Top', 'Bewertung am Ticket'); + [$s, $r] = $api('GET', '/api/staff/v1/csat.php?days=7', null, $agent); + $check($s === 200 && $r['enabled'] === false && $r['stats']['answered'] === 1 && $r['stats']['avg'] == 5 && count($r['stats']['dist']) === 5 && $r['stats']['byStaff'][0]['name'] === 'Agent', 'Auswertung über die Schnittstelle'); + + // Vorlagen + [$s, $r] = $api('GET', '/api/staff/v1/ticket_templates.php', null, $agent); + $check($s === 200 && $r['templates'] === [], 'noch keine Vorlagen'); + $p->prepare("INSERT INTO ticket_templates (name, subject, body, department_id, is_public, is_active, sort_order, created_at) VALUES ('Neuer Mitarbeiter','Neu: ','Name:',?,1,1,1,?)")->execute([$dept, $nowS]); + $p->prepare("INSERT INTO ticket_templates (name, subject, is_public, is_active, sort_order, created_at) VALUES ('Inaktiv','x',1,0,2,?)")->execute([$nowS]); + [, $r] = $api('GET', '/api/staff/v1/ticket_templates.php', null, $agent); + $check(count($r['templates']) === 1 && $r['templates'][0]['name'] === 'Neuer Mitarbeiter' && $r['templates'][0]['departmentId'] === $dept && $r['templates'][0]['body'] === 'Name:', 'Vorlagen für die Apps (nur aktive)'); + [$s, ] = $api('GET', '/api/staff/v1/ticket_templates.php'); + $check($s === 401, 'Vorlagen nur mit Anmeldung'); + + // Statusseite + [$s, $r] = $api('GET', '/api/staff/v1/status.php', null, $agent); + $check($s === 200 && $r['canManage'] === false && $r['status']['enabled'] === false && $r['status']['components'] === [], 'Statusseite lesen (Mitarbeiter)'); + [$s, ] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_enabled', 'enabled' => true], $agent); + $check($s === 403, 'Mitarbeiter dürfen die Statusseite nicht ändern'); + $p->prepare("INSERT INTO status_components (name, status, sort_order, created_at) VALUES ('Webseite','operational',1,?)")->execute([$nowS]); + $comp = (int) $p->lastInsertId(); + [$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_enabled', 'enabled' => true], $admin); + $check($s === 200 && $r['status']['enabled'] === true && $r['canManage'] === true, 'Administrator schaltet die Statusseite ein'); + [$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'create_incident', 'title' => 'Portal langsam', 'body' => 'Wir prüfen das.', 'status' => 'investigating', 'componentId' => $comp, 'componentState' => 'degraded'], $admin); + $check($s === 200 && $r['status']['overall']['key'] === 'degraded' && count($r['status']['incidents']) === 1 && $r['status']['components'][0]['status'] === 'degraded', 'Meldung veröffentlichen'); + $inc = (int) ($r['status']['incidents'][0]['id'] ?? 0); + [$s, ] = $api('POST', '/api/staff/v1/status.php', ['action' => 'create_incident', 'title' => ''], $admin); + $check($s === 422, 'Meldung ohne Titel'); + [$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_component', 'id' => $comp, 'status' => 'outage'], $admin); + $check($r['status']['overall']['key'] === 'outage', 'Dienst-Zustand ändern'); + [$s, ] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_component', 'id' => $comp, 'status' => 'kaputt'], $admin); + $check($s === 422, 'ungültiger Zustand'); + [$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'update_incident', 'id' => $inc, 'status' => 'resolved', 'body' => 'Behoben.'], $admin); + $check($r['status']['overall']['key'] === 'operational' && $r['status']['incidents'][0]['status'] === 'resolved' && count($r['status']['incidents'][0]['updates']) === 2, 'Meldung beheben setzt den Dienst zurück'); + [, $r] = $api('GET', '/api/staff/v1/status.php', null, $agent); + $check($r['status']['enabled'] === true && $r['status']['title'] === 'Systemstatus', 'Mitarbeiter sehen den Stand'); +} finally { + if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); } +} + +usleep(300000); +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-apicss-')) $rm($work); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "API CSAT, Status und Vorlagen checks passed ({$total} Prüfungen).\n"; diff --git a/DeskHub-Webseite/tests/catalog_preview.php b/DeskHub-Webseite/tests/catalog_preview.php index 0ea24a3f..2049a24d 100644 --- a/DeskHub-Webseite/tests/catalog_preview.php +++ b/DeskHub-Webseite/tests/catalog_preview.php @@ -136,6 +136,21 @@ $t1 = theme_catalog_cached(); $n1 = count($requests); $t2 = theme_catalog_cached(); $check($t1['ok'] && $t2['ok'] && count($requests) === $n1 && $n1 === $before + 1, 'Theme-Katalog wird beim ersten Aufruf geladen und danach aus dem Zwischenspeicher gezeigt'); +$tc = json_decode((string) get_setting('theme_catalog_cache', ''), true); +$tc['at'] = time() - 400; +set_setting('theme_catalog_cache', json_encode($tc)); +$n3 = count($requests); +theme_catalog_cached(); +$check(count($requests) === $n3 + 1, 'Theme-Katalog wird nach über 5 Minuten von selbst neu geladen'); +$tc = json_decode((string) get_setting('theme_catalog_cache', ''), true); +$tc['at'] = time() - 400; +set_setting('theme_catalog_cache', json_encode($tc)); +$saved = $GLOBALS['deskhub_http_get']; +$GLOBALS['deskhub_http_get'] = fn(string $url, int $max): array => [0, null, 'Verbindung fehlgeschlagen']; +$tStale = theme_catalog_cached(); +$check($tStale['ok'] && !empty($tStale['stale']) && is_array($tStale['themes']), 'Theme-Katalog: bei Serverausfall bleibt der letzte Stand sichtbar'); +$GLOBALS['deskhub_http_get'] = $saved; +set_setting('theme_catalog_fail_at', '0'); $check(plugin_updates_available($pdo = get_db()) === [], 'ohne installierte Plugins gibt es keine Update-Hinweise'); catalog_refresh_background(); diff --git a/DeskHub-Webseite/tests/csat_status_templates.php b/DeskHub-Webseite/tests/csat_status_templates.php new file mode 100644 index 00000000..2f967448 --- /dev/null +++ b/DeskHub-Webseite/tests/csat_status_templates.php @@ -0,0 +1,161 @@ +prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, is_spam, closed_at, resolved_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)') + ->execute(["CS-{$n}", "Betreff {$n}", 'Erika Muster', $email, 1, 2, $status, $source, $spam, $status === 'closed' ? $closedAt : null, $status === 'resolved' ? $closedAt : null, $now, $now]); + return (int) $pdo->lastInsertId(); +}; +$old = date('Y-m-d H:i:s', time() - 3600); +$t1 = $mk('closed', 'erika@kunde.example', $old); +$check(csat_send_due($pdo) === 0, 'ausgeschaltet: nichts wird gesendet'); +csat_set_enabled(true, 30); +$check(csat_enabled() && get_setting('csat_since', '') !== '', 'Einschalten merkt sich den Zeitpunkt'); +$check(csat_send_due($pdo) === 0, 'Ticket aus der Zeit vor dem Einschalten bekommt keine Abfrage'); +set_setting('csat_since', date('Y-m-d H:i:s', time() - 86400)); +$tNew = $mk('closed', 'neu@kunde.example', date('Y-m-d H:i:s', time() - 60)); +$tSpam = $mk('closed', 'spam@kunde.example', $old, 'web', 1); +$tChat = $mk('closed', 'chat@kunde.example', $old, 'chat'); +$tOpen = $mk('open', 'offen@kunde.example', null); +$tNoMail = $mk('closed', '', $old); +$tBad = $mk('closed', 'kein-email', $old); +$tRes = $mk('resolved', 'geloest@kunde.example', $old); +$sent = csat_send_due($pdo); +$rows = $pdo->query('SELECT ticket_id FROM ticket_csat ORDER BY ticket_id')->fetchAll(PDO::FETCH_COLUMN); +$check($sent === 2 && array_map('intval', $rows) === [$t1, $tRes], 'gesendet nur für geschlossene/gelöste Tickets mit Adresse, nach der Wartezeit, ohne Spam und Chat: ' . json_encode($rows)); +$check(csat_send_due($pdo) === 0, 'pro Ticket nur eine Abfrage'); +$pdo->prepare('UPDATE tickets SET updated_at = ?, closed_at = ? WHERE id = ?')->execute([$old, $old, $tNew]); +$check(csat_send_due($pdo) === 1, 'nach Ablauf der Wartezeit kommt auch das neuere Ticket dran'); + +$row = csat_for_ticket($pdo, $t1); +$token = (string) $row['token']; +$check(preg_match('/^[a-f0-9]{40}$/', $token) === 1 && csat_find_by_token($pdo, $token)['ticket_number'] === 'CS-5001', 'Token und Suche'); +$check(csat_find_by_token($pdo, 'zzz') === null && csat_find_by_token($pdo, str_repeat('a', 40)) === null, 'ungültiger Token'); +$check(str_contains(csat_mail_html($pdo->query("SELECT * FROM tickets WHERE id = {$t1}")->fetch(), $token), 'csat.php?t=' . $token . '&r=5') || str_contains(csat_mail_html($pdo->query("SELECT * FROM tickets WHERE id = {$t1}")->fetch(), $token), 'r=5'), 'Mail enthält die fünf Sterne-Links'); +$check(!csat_record($pdo, $token, 0, '')['ok'] && !csat_record($pdo, $token, 6, '')['ok'], 'Bewertung muss 1 bis 5 sein'); +$check(csat_record($pdo, $token, 4, ' Schnell und freundlich ')['ok'], 'Bewertung wird gespeichert'); +$check(!csat_record($pdo, $token, 1, '')['ok'], 'zweite Bewertung wird abgelehnt'); +$row = csat_for_ticket($pdo, $t1); +$check((int) $row['rating'] === 4 && $row['comment'] === 'Schnell und freundlich' && $row['answered_at'] !== null, 'Bewertung und Kommentar stehen in der Datenbank'); +$check(!csat_record($pdo, 'x', 3, '')['ok'], 'unbekannter Link'); +$tok2 = (string) csat_for_ticket($pdo, $tRes)['token']; +csat_record($pdo, $tok2, 2, ''); +$st = csat_stats($pdo, date('Y-m-d 00:00:00', time() - 86400), date('Y-m-d 23:59:59'), null); +$check($st['sent'] === 3 && $st['answered'] === 2 && $st['avg'] === 3.0 && $st['rate'] === 66.7 && $st['dist'][4] === 1 && $st['dist'][2] === 1 && count($st['recent']) === 2, 'Auswertung: ' . json_encode($st)); +$check(csat_stats($pdo, '2000-01-01 00:00:00', '2000-12-31 23:59:59', null)['sent'] === 0 && csat_stats($pdo, $now, $now, [])['sent'] === 0, 'leere Zeiträume und Abteilungen'); +$check(csat_stars(4) === '★★★★☆' && csat_stars(9) === '★★★★★', 'Sterne-Text'); +csat_set_enabled(false, 30); +$check(!csat_enabled(), 'Ausschalten'); + +// ── Statusseite ───────────────────────────────────────────────────────────── +$check(!status_page_enabled() && status_overall($pdo)['key'] === 'operational', 'Statusseite standardmäßig aus, ohne Dienste alles in Ordnung'); +$web = status_save_component($pdo, null, 'Webseite', 'Kundenportal', 10); +$mail = status_save_component($pdo, null, 'E-Mail', '', 20); +$check($web !== null && $mail !== null && status_save_component($pdo, null, ' ', '', 0) === null, 'Dienste anlegen (leerer Name abgelehnt)'); +$check(!status_set_component($pdo, $web, 'kaputt') && status_set_component($pdo, $web, 'degraded') && status_overall($pdo)['key'] === 'degraded', 'Zustand setzen, ungültiger Zustand abgelehnt'); +status_set_component($pdo, $mail, 'outage'); +$check(status_overall($pdo)['key'] === 'outage' && status_overall($pdo)['label'] === 'Ausfall', 'schlechtester Zustand zählt'); +status_set_component($pdo, $mail, 'maintenance'); +status_set_component($pdo, $web, 'operational'); +$check(status_overall($pdo)['key'] === 'maintenance', 'Wartung'); +status_set_component($pdo, $mail, 'operational'); +$r = status_create_incident($pdo, ' ', 'x', 'investigating', null, null, 1); +$check(!$r['ok'], 'Meldung ohne Titel wird abgelehnt'); +$r = status_create_incident($pdo, 'E-Mail verzögert', 'Wir untersuchen das.', 'investigating', $mail, 'degraded', 1); +$check($r['ok'] && status_overall($pdo)['key'] === 'degraded', 'Meldung setzt den Dienst auf „eingeschränkt“'); +$inc = (int) $r['id']; +$check(count(status_incidents($pdo, 14, true)) === 1 && status_incidents($pdo)[0]['updates'][0]['body'] === 'Wir untersuchen das.', 'offene Meldung mit erstem Update'); +$other = status_create_incident($pdo, 'Zweite Störung', '', 'identified', $mail, 'outage', 1)['id']; +$check(status_update_incident($pdo, $inc, 'resolved', 'Behoben.', 1)['ok'] && status_overall($pdo)['key'] === 'outage', 'Dienst bleibt gestört, solange eine andere Meldung offen ist'); +$check(status_update_incident($pdo, $other, 'resolved', 'Auch behoben.', 1)['ok'] && status_overall($pdo)['key'] === 'operational', 'nach der letzten Meldung wieder „In Betrieb“'); +$check(!status_update_incident($pdo, 999, 'resolved', '', 1)['ok'] && !status_update_incident($pdo, $inc, 'quatsch', '', 1)['ok'], 'unbekannte Meldung / Stufe'); +$check(status_incidents($pdo, 14, true) === [] && count(status_incidents($pdo, 14)) === 2 && status_incidents($pdo)[0]['resolved_at'] !== null, 'behobene Meldungen bleiben 14 Tage sichtbar'); +$pdo->prepare('UPDATE status_incidents SET created_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 30 * 86400), $inc]); +$check(count(status_incidents($pdo, 14)) === 1, 'ältere behobene Meldungen verschwinden'); +$sum = status_summary($pdo); +$check($sum['enabled'] === false && count($sum['components']) === 2 && $sum['overall']['key'] === 'operational' && count($sum['componentStates']) === 4 && str_ends_with($sum['url'], '/status.php'), 'Zusammenfassung für die Apps'); +status_delete_incident($pdo, $inc); +status_delete_component($pdo, $mail); +$check(count(status_components($pdo)) === 1 && (int) $pdo->query("SELECT COUNT(*) FROM status_incident_updates WHERE incident_id = {$inc}")->fetchColumn() === 0, 'Löschen räumt die Updates mit auf'); + +// ── Ticket-Vorlagen ───────────────────────────────────────────────────────── +$check(!ticket_template_save($pdo, null, ['name' => ' '])['ok'], 'Vorlage ohne Namen wird abgelehnt'); +$a = ticket_template_save($pdo, null, ['name' => 'Neuer Mitarbeiter', 'subject' => 'Neuer Mitarbeiter: ', 'body' => "Name:\nStartdatum:", 'department_id' => '1', 'priority_id' => '2', 'is_public' => '1', 'sort_order' => '10']); +$b = ticket_template_save($pdo, null, ['name' => 'Interne Vorlage', 'subject' => 'Intern', 'department_id' => '9999', 'is_public' => '', 'sort_order' => '20']); +$check($a['ok'] && $b['ok'], 'Vorlagen anlegen'); +$check(count(ticket_templates_list($pdo)) === 2 && count(ticket_templates_list($pdo, true)) === 1 && ticket_templates_list($pdo, true)[0]['name'] === 'Neuer Mitarbeiter', 'Kunden sehen nur öffentliche Vorlagen'); +$tb = ticket_template_get($pdo, $b['id']); +$check($tb['department_id'] === null && ticket_template_get($pdo, $b['id'], true) === null && ticket_template_get($pdo, 9999) === null, 'unbekannte Abteilung wird verworfen, nicht öffentliche Vorlage bleibt Kunden verborgen'); +$check(ticket_template_save($pdo, $a['id'], ['name' => 'Neuer Mitarbeiter 2', 'is_public' => '1', 'is_active' => '0'])['ok'] && ticket_template_get($pdo, $a['id']) === null && count(ticket_templates_list($pdo, false, true)) === 2, 'Vorlage deaktivieren'); +ticket_template_save($pdo, $a['id'], ['name' => 'Neuer Mitarbeiter', 'subject' => 'Neuer Mitarbeiter: ', 'body' => "Name:\nStartdatum:", 'department_id' => '1', 'is_public' => '1', 'is_active' => '1']); +$p = ticket_template_payload(ticket_template_get($pdo, $a['id'])); +$check($p['name'] === 'Neuer Mitarbeiter' && $p['departmentId'] === 1 && $p['priorityId'] === null && $p['isPublic'] === true && $p['body'] === "Name:\nStartdatum:", 'Vorlage als Feldliste'); +ticket_template_delete($pdo, $b['id']); +$check(count(ticket_templates_list($pdo)) === 1, 'Vorlage löschen'); + +// ── Seiten ────────────────────────────────────────────────────────────────── +$_SERVER['REQUEST_METHOD'] = 'GET'; +$render = function (string $file, array $get = [], array $post = [], string $method = 'GET') { + $_GET = $get; + $_POST = $post; + $_SERVER['REQUEST_METHOD'] = $method; + ob_start(); + try { + include dirname(__DIR__) . '/' . $file; + } finally { + $html = (string) ob_get_clean(); + } + return $html; +}; +// Bewertungsseite +csat_set_enabled(true, 30); +$pdo->prepare('UPDATE ticket_csat SET rating = NULL, comment = NULL, answered_at = NULL WHERE ticket_id = ?')->execute([$t1]); +$html = $render('csat.php', ['t' => $token, 'r' => '5']); +$check(str_contains($html, 'Wie zufrieden waren Sie?') && str_contains($html, 'CS-5001') && str_contains($html, 'value="5" style="position:absolute;opacity:0;width:0;height:0" checked'), 'Bewertungsseite zeigt das Ticket mit vorgewähltem Stern'); +$check((int) csat_for_ticket($pdo, $t1)['answered_at'] === 0 && csat_for_ticket($pdo, $t1)['answered_at'] === null, 'Aufrufen des Links bewertet noch nichts'); +$html = $render('csat.php', [], ['t' => $token, 'rating' => '3', 'comment' => 'ok'], 'POST'); +$check(str_contains($html, 'Vielen Dank') && (int) csat_for_ticket($pdo, $t1)['rating'] === 3, 'Absenden speichert die Bewertung'); +$check(str_contains($render('csat.php', ['t' => $token]), 'Bereits bewertet'), 'zweiter Aufruf: bereits bewertet'); +$check(str_contains($render('csat.php', ['t' => 'unsinn']), 'Link nicht gültig'), 'ungültiger Link'); +// Statusseite +set_setting('status_page_enabled', '1'); +status_set_component($pdo, $web, 'degraded'); +status_create_incident($pdo, 'Portal langsam', 'Wir arbeiten daran.', 'identified', $web, null, 1); +$html = $render('status.php'); +$check(str_contains($html, 'Systemstatus') && str_contains($html, 'Teilweise eingeschränkt') && str_contains($html, 'Webseite') && str_contains($html, 'Portal langsam') && str_contains($html, 'Wir arbeiten daran.') && str_contains($html, 'Ursache gefunden'), 'Statusseite zeigt Dienste und Meldung'); +$check(str_contains($html, 'href="/status.php"'), 'Link „Status“ im Kundenbereich'); +// Ticket-Formular mit Vorlage +$tpl = (int) $a['id']; +$html = $render('ticket_new.php', ['template' => (string) $tpl]); +$check(str_contains($html, 'value="Neuer Mitarbeiter:"') && str_contains($html, "Name:\nStartdatum:") && str_contains($html, 'ticket_new.php?template=' . $tpl) && str_contains($html, 'Ohne Vorlage'), 'Kundenformular wird von der Vorlage vorbelegt'); +$html = $render('ticket_new.php'); +$check(!str_contains($html, 'value="Neuer Mitarbeiter:"') && str_contains($html, 'Womit können wir helfen'), 'ohne Vorlage bleibt das Formular leer, Vorlagen werden angeboten'); +$html = $render('ticket_new.php', ['template' => '99999']); +$check(!str_contains($html, 'Ohne Vorlage'), 'unbekannte Vorlage wird ignoriert'); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo 'CSAT, Status und Vorlagen checks passed.' . "\n"; diff --git a/DeskHub-Webseite/tests/error_alert.php b/DeskHub-Webseite/tests/error_alert.php new file mode 100644 index 00000000..abb5665a --- /dev/null +++ b/DeskHub-Webseite/tests/error_alert.php @@ -0,0 +1,86 @@ + E_ERROR, 'message' => "Uncaught Error: Call to undefined function foo() in {$root}/staff/x.php:12", 'file' => $root . '/staff/x.php', 'line' => 12]; + +// Empfänger +$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Chef','chef@firma.example','x','admin',1,?)")->execute([db_now()]); +set_setting('admin_email', ''); +$check(in_array('chef@firma.example', error_alert_recipients($pdo), true), 'ohne eigene Adresse gehen die Mails an die Administratoren'); +set_setting('admin_email', 'it@firma.example'); +$check(error_alert_recipients($pdo) === ['it@firma.example'], 'die Admin-Adresse der Seite hat Vorrang'); +set_setting('error_alert_email', 'a@firma.example, b@firma.example; kaputt'); +$check(error_alert_recipients($pdo) === ['a@firma.example', 'b@firma.example'], 'eigene Empfänger (mehrere, ungültige werden übersprungen)'); + +// Inhalt +$_SERVER['REQUEST_URI'] = '/staff/tickets.php?token=GEHEIM&x=1'; +[$subject, $html] = error_alert_compose($err); +$check(str_contains($html, 'staff/x.php') && str_contains($html, 'Zeile 12') && str_contains($html, '/staff/tickets.php'), 'Mail nennt Datei, Zeile und Seite'); +$check(!str_contains($html, 'GEHEIM') && !str_contains($html, $root), 'Mail enthält weder Adress-Parameter noch den Serverpfad'); +[, $evil] = error_alert_compose(['message' => '', 'file' => 'x', 'line' => 1]); +$check(!str_contains($evil, '