From 9b1b7c0d6e8099e9bc2facb9cc99d1884791497e Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sun, 1 Nov 2026 11:15:37 +0000 Subject: [PATCH] Upload via GUI (364 Dateien) --- DeskHub-Webseite/includes/app_downloads.php | 4 +- DeskHub-Webseite/includes/functions.php | 6 +- DeskHub-Webseite/includes/integrations.php | 160 +++++++++++++++++++- DeskHub-Webseite/includes/login_guard.php | 9 +- DeskHub-Webseite/includes/notifications.php | 2 +- DeskHub-Webseite/includes/problems.php | 7 +- DeskHub-Webseite/includes/status_page.php | 71 +++++++-- DeskHub-Webseite/includes/vacation.php | 30 +++- 8 files changed, 262 insertions(+), 27 deletions(-) diff --git a/DeskHub-Webseite/includes/app_downloads.php b/DeskHub-Webseite/includes/app_downloads.php index 2814aed8..425a588a 100644 --- a/DeskHub-Webseite/includes/app_downloads.php +++ b/DeskHub-Webseite/includes/app_downloads.php @@ -16,6 +16,8 @@ function app_platforms(): array 'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'], 'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'], 'minecraft' => ['label' => 'Minecraft', 'ext' => 'jar', 'stored' => 'DeskHub-Minecraft.jar', 'download' => 'DeskHub-Minecraft.jar', 'magic' => 'PK', 'mime' => 'application/java-archive'], + 'wordpress' => ['label' => 'WordPress', 'ext' => 'zip', 'stored' => 'deskhub-tickets-wordpress.zip', 'download' => 'deskhub-tickets-wordpress.zip', 'magic' => 'PK', 'mime' => 'application/zip'], + 'chrome' => ['label' => 'Chrome', 'ext' => 'zip', 'stored' => 'deskhub-chrome-erweiterung.zip', 'download' => 'deskhub-chrome-erweiterung.zip', 'magic' => 'PK', 'mime' => 'application/zip'], ]; } @@ -27,7 +29,7 @@ function app_downloads_dir(): string /** Official publisher release endpoint; the access key stays on the server. */ function app_publisher_endpoint(string $platform, bool $download = false): string { - if (!in_array($platform, ['android', 'windows', 'minecraft'], true) || !function_exists('catalog_defaults')) return ''; + if (!isset(app_platforms()[$platform]) || !function_exists('catalog_defaults')) return ''; $base = (string) (catalog_defaults()['plugins_url'] ?? ''); $parts = parse_url($base); if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return ''; diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php index 24dd89a1..7706981e 100644 --- a/DeskHub-Webseite/includes/functions.php +++ b/DeskHub-Webseite/includes/functions.php @@ -332,7 +332,7 @@ function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldS } if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) { ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']); - $pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$ticketId]); + $pdo->prepare('UPDATE tickets SET is_overdue = 1, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } return; } @@ -1128,7 +1128,7 @@ function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?stri * frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */ function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void { - $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') + $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?') ->execute([$staffId, db_now(), $ticketId]); } @@ -1166,7 +1166,7 @@ function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string * frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */ function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void { - $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') + $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?') ->execute([$staffId, db_now(), $ticketId]); } diff --git a/DeskHub-Webseite/includes/integrations.php b/DeskHub-Webseite/includes/integrations.php index 6de51e25..3a79fcb4 100644 --- a/DeskHub-Webseite/includes/integrations.php +++ b/DeskHub-Webseite/includes/integrations.php @@ -49,8 +49,138 @@ function api_key_verify(PDO $pdo, string $key, string $ip): ?array return $row; } -// ── Ticket über die öffentliche API anlegen ───────────────────────────────── +// ── Kundenformular für Fremdsysteme (eigene Felder, Formular der Abteilung) ────────────────── +/** + * Das Kundenformular für ein Hilfethema oder eine Abteilung, wie es ticket_new.php zeigt: Hat die Abteilung ein eigenes Formular, + * gelten dessen Texte und Felder; sonst die allgemeinen Felder und die des Hilfethemas. + * + * @return array{topicId:?int,departmentId:?int,own:?array,fields:array} + * @throws AdminModelException wenn das Hilfethema nicht existiert + */ +function public_form_definition(PDO $pdo, ?int $topicId, ?int $deptId): array +{ + if ($topicId !== null) { + $t = $pdo->prepare('SELECT * FROM help_topics WHERE id = ? AND is_active = 1'); + $t->execute([$topicId]); + $topic = $t->fetch(); + if (!$topic) throw new AdminModelException('Das Hilfethema existiert nicht oder ist deaktiviert.', 'INVALID_TOPIC'); + $deptId = $deptId ?? (int) $topic['department_id']; + } + $own = $deptId ? department_form($pdo, $deptId) : null; + if ($own) { + $fields = $own['fields']; + } else { + $all = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll(); + $fields = array_values(array_filter($all, fn(array $f) => $f['help_topic_id'] === null || ($topicId !== null && (int) $f['help_topic_id'] === $topicId))); + } + return ['topicId' => $topicId, 'departmentId' => $deptId, 'own' => $own, 'fields' => $fields]; +} + +/** Die Felder eines Formulars für die API-Antwort. Hinweistexte kommen auch als sicheres HTML (Links mit absoluter Adresse). */ +function public_form_fields_payload(array $fields): array +{ + $base = base_url(); + $out = []; + foreach ($fields as $f) { + $type = in_array($f['field_type'], ['text', 'textarea', 'select', 'radio', 'checkbox', 'number', 'date'], true) ? $f['field_type'] : 'text'; + $options = []; + if (in_array($type, ['select', 'radio'], true)) { + foreach (preg_split('/\r?\n/', trim((string) ($f['options'] ?? ''))) ?: [] as $o) { + $o = trim($o); + if ($o !== '') $options[] = $o; + } + } + $help = (string) ($f['help_text'] ?? ''); + $html = $help !== '' ? str_replace('href="/', 'href="' . $base . '/', form_help_html($help, (string) $f['label'])) : ''; + $out[] = [ + 'id' => (int) $f['id'], + 'label' => (string) $f['label'], + 'type' => $type, + 'required' => !empty($f['is_required']), + 'options' => $options, + 'help' => $help, + 'helpHtml' => $html, + 'width' => in_array($f['form_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $f['form_width'] : 'full', + 'section' => (string) ($f['_section'] ?? 'extra'), + 'sectionTitle' => (string) ($f['_section_title'] ?? ''), + 'radioInline' => !empty($f['radio_inline']), + ]; + } + return $out; +} + +/** Das ganze Formular für die API-Antwort (Texte und Felder). */ +function public_form_payload(array $def): array +{ + $o = $def['own']; + return [ + 'helpTopicId' => $def['topicId'], + 'departmentId' => $def['departmentId'], + 'ownForm' => $o !== null, + 'title' => (string) ($o['title'] ?? ''), + 'intro' => (string) ($o['intro'] ?? ''), + 'subject' => ['show' => $o ? (bool) $o['subject_show'] : true, 'label' => (string) ($o['subject_label'] ?? 'Betreff')], + 'message' => ['show' => $o ? (bool) ($o['message_show'] ?? true) : true, 'label' => (string) ($o['message_label'] ?? 'Beschreibung'), + 'hint' => (string) ($o['message_hint'] ?? 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…')], + 'submitLabel' => (string) ($o['submit_label'] ?? 'Ticket erstellen'), + 'fields' => public_form_fields_payload($def['fields']), + ]; +} + +/** Wert eines Feldes aus der Anfrage, in der Form, wie ticket_new.php ihn speichert (Checkbox: "1" oder "0"). */ +function public_form_field_value(array $f, array $input): string +{ + $raw = $input[(int) $f['id']] ?? $input[(string) $f['id']] ?? null; + if ($f['field_type'] === 'checkbox') { + $norm = is_string($raw) ? strtolower(trim($raw)) : $raw; + return in_array($norm, [true, 1, '1', 'on', 'yes', 'ja', 'true'], true) ? '1' : '0'; + } + return is_scalar($raw) ? trim((string) $raw) : ''; +} + +/** + * Prüft die gelieferten Feldwerte gegen das Formular (Pflichtfelder, Auswahlwerte, Zahl, Datum) wie ticket_new.php. + * + * @return array Feld-ID => Wert + * @throws AdminModelException mit Code INVALID_FIELD (die Meldung ist für den Besucher gedacht) + */ +function public_form_validate_fields(array $fields, array $input): array +{ + $values = []; + foreach ($fields as $f) { + $val = public_form_field_value($f, $input); + $label = (string) $f['label']; + $type = $f['field_type']; + if (mb_strlen($val) > 10000) throw new AdminModelException('Die Angabe bei „' . $label . '“ ist zu lang.', 'INVALID_FIELD'); + if (!empty($f['is_required']) && $type !== 'checkbox' && $val === '') throw new AdminModelException('Bitte füllen Sie das Feld „' . $label . '“ aus.', 'INVALID_FIELD'); + if (!empty($f['is_required']) && $type === 'checkbox' && $val !== '1') throw new AdminModelException('Bitte bestätigen Sie „' . $label . '“.', 'INVALID_FIELD'); + if (in_array($type, ['select', 'radio'], true) && $val !== '' && !in_array($val, array_map('trim', preg_split('/\r?\n/', (string) $f['options']) ?: []), true)) { + throw new AdminModelException('Bitte wählen Sie bei „' . $label . '“ eine der angebotenen Möglichkeiten.', 'INVALID_FIELD'); + } + if ($type === 'number' && $val !== '' && !is_numeric($val)) throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ eine Zahl an.', 'INVALID_FIELD'); + if ($type === 'date' && $val !== '' && !(preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $val, $d) && checkdate((int) $d[2], (int) $d[3], (int) $d[1]))) { + throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ ein gültiges Datum an (JJJJ-MM-TT).', 'INVALID_FIELD'); + } + $values[(int) $f['id']] = $val; + } + return $values; +} + +/** + * Ist der Schlüssel gültig, wird aber nur wegen der IP-Beschränkung abgelehnt? So kann die API dem Aufrufer sagen, welche Adresse + * DeskHub sieht (hinter einem Proxy oder Cloudflare ist das oft nicht die erwartete). Verrät nichts, was der Aufrufer mit dem Schlüssel nicht ohnehin weiß. + */ +function api_key_ip_blocked(PDO $pdo, string $key, string $ip): bool +{ + if (!preg_match('/^dh_[a-f0-9]{48}$/', $key)) return false; + $stmt = $pdo->prepare('SELECT ip_whitelist FROM api_keys WHERE key_hash = ? AND is_active = 1'); + $stmt->execute([hash('sha256', $key)]); + $row = $stmt->fetch(); + return $row && !empty($row['ip_whitelist']) && !in_array($ip, explode(',', $row['ip_whitelist']), true); +} + +// ── Ticket über die öffentliche API anlegen ───────────────────────────────── /** * Legt ein Ticket für einen Fremdsystem-Aufruf an (Quelle "api"), wendet Ticket-Regeln und SLA-Pläne an, * benachrichtigt Mitarbeiter und Kunde und löst den Webhook "ticket.created" aus. @@ -64,11 +194,33 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts $email = trim((string) ($in['email'] ?? '')); $subject = trim((string) ($in['subject'] ?? '')); $message = trim((string) ($in['message'] ?? '')); + // Mit "fields" liefert der Aufrufer die Angaben eines Kundenformulars (eigene Felder, Formular der Abteilung). Ohne "fields" bleibt alles wie bisher. + $formDef = null; + $fieldInput = []; + if (isset($in['fields']) && is_array($in['fields'])) { + $fieldInput = $in['fields']; + $preTopic = am_int_or_null($in, 'helpTopicId'); + $preDept = am_int_or_null($in, 'departmentId'); + $formDef = public_form_definition($pdo, $preTopic, $preTopic === null ? ($preDept ?: (int) get_setting('default_department_id', '1')) : $preDept); + $own = $formDef['own']; + if ($own && !$own['subject_show'] && $subject === '') $subject = 'Anfrage ' . $own['department_name']; + if ($own && empty($own['message_show']) && $message === '') { + // Ohne Beschreibungsfeld besteht der Ticket-Text aus den Angaben der eigenen Felder + $lines = []; + foreach ($formDef['fields'] as $ff) { + $v = public_form_field_value($ff, $fieldInput); + if ($ff['field_type'] === 'checkbox') $v = $v === '1' ? 'Ja' : 'Nein'; + if ($v !== '') $lines[] = $ff['label'] . ': ' . $v; + } + $message = $lines ? implode("\n", $lines) : 'Anfrage ohne weitere Angaben'; + } + } if ($name === '' || mb_strlen($name) > 120) throw new AdminModelException('Bitte einen Namen angeben (höchstens 120 Zeichen).', 'INVALID_NAME'); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new AdminModelException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL'); if ($subject === '' || mb_strlen($subject) > 255) throw new AdminModelException('Bitte einen Betreff angeben (höchstens 255 Zeichen).', 'INVALID_SUBJECT'); if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE'); if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403); + $fieldValues = $formDef ? public_form_validate_fields($formDef['fields'], $fieldInput) : []; $topicId = am_int_or_null($in, 'helpTopicId'); $deptId = am_int_or_null($in, 'departmentId'); @@ -110,6 +262,12 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts $ticketId = (int) $pdo->lastInsertId(); $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)') ->execute([$ticketId, 'customer', $message, $now]); + if ($fieldValues) { + $cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)'); + foreach ($fieldValues as $fieldId => $val) { + if ($val !== '') $cfStmt->execute([$ticketId, $fieldId, $val]); + } + } log_activity($pdo, $ticketId, null, (string) ($opts['activity'] ?? 'Ticket über die API erstellt')); if (!empty($opts['tag'])) apply_tag_to_ticket($pdo, $ticketId, (string) $opts['tag']); diff --git a/DeskHub-Webseite/includes/login_guard.php b/DeskHub-Webseite/includes/login_guard.php index c17dea3d..3fa8a07a 100644 --- a/DeskHub-Webseite/includes/login_guard.php +++ b/DeskHub-Webseite/includes/login_guard.php @@ -197,6 +197,8 @@ function login_guard_locked(string $email, string $channel): void if (!login_guard_enabled()) return; try { $pdo = get_db(); + // Steuerzeichen und Zeilenumbrüche raus (die Adresse steht im Betreff), Länge begrenzen + $email = mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/', ' ', $email)), 0, 120); $cut = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60); $st = $pdo->prepare("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND LOWER(email) = LOWER(?) AND created_at >= ?"); $st->execute([$email, $cut]); @@ -204,13 +206,14 @@ function login_guard_locked(string $email, string $channel): void $st = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1'); $st->execute([$email]); $staff = $st->fetch() ?: null; - login_guard_record($pdo, 'locked', $staff ? (int) $staff['id'] : null, $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt'); + if (!$staff) return; // unbekannte Adresse: kein Eintrag und keine Mail (sonst lässt sich der Posteingang der Administratoren füllen); die Fehlversuche stehen schon im Protokoll + login_guard_record($pdo, 'locked', (int) $staff['id'], $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt'); $ip = client_ip(); $msg = '

Anmeldung gesperrt

' . '

Für das Konto ' . e($email) . ' gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.

' . '

Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.

'; - if ($staff) send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg)); - login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, $staff ? (string) $staff['email'] : null); + send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg)); + login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, (string) $staff['email']); } catch (Throwable $e) { error_log('login_guard_locked: ' . $e->getMessage()); } diff --git a/DeskHub-Webseite/includes/notifications.php b/DeskHub-Webseite/includes/notifications.php index 6b5efa27..da711230 100644 --- a/DeskHub-Webseite/includes/notifications.php +++ b/DeskHub-Webseite/includes/notifications.php @@ -89,7 +89,7 @@ function notify_new_reply_channels(array $ticket, string $excerpt): void // Antwort des Kunden holt das Ticket aus der Wiedervorlage und setzt die Erinnerung zurück; Beobachter erfahren davon $pdo = get_db(); ticket_snooze_clear($pdo, (int) $ticket['id'], null, 'Wiedervorlage beendet (Kunde hat geantwortet)'); - $pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL WHERE id = ?')->execute([$ticket['id']]); + $pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticket['id']]); ticket_followers_notify($ticket, 'Neue Kundenantwort'); } catch (Throwable $e) { // Datenbank noch nicht aktualisiert: nicht schlimm diff --git a/DeskHub-Webseite/includes/problems.php b/DeskHub-Webseite/includes/problems.php index cc1a6108..8ea98770 100644 --- a/DeskHub-Webseite/includes/problems.php +++ b/DeskHub-Webseite/includes/problems.php @@ -106,9 +106,12 @@ function ticket_problem_link(PDO $pdo, int $problemId, int $ticketId, int $staff return true; } -function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): void +/** Löst die Verknüpfung. Liefert false, wenn es sie nicht gab. */ +function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): bool { - $pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?')->execute([$problemId, $ticketId]); + $stmt = $pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?'); + $stmt->execute([$problemId, $ticketId]); + return $stmt->rowCount() > 0; } function ticket_problems_for_ticket(PDO $pdo, int $ticketId): array diff --git a/DeskHub-Webseite/includes/status_page.php b/DeskHub-Webseite/includes/status_page.php index 9fa1d508..fd21e0c7 100644 --- a/DeskHub-Webseite/includes/status_page.php +++ b/DeskHub-Webseite/includes/status_page.php @@ -42,6 +42,16 @@ function status_app_connection(PDO $pdo, string $platform): array return ['online' => $lastSeen !== null && strtotime($lastSeen) >= strtotime($cutoff), 'last_seen' => $lastSeen, 'timeout_minutes' => $timeoutMinutes]; } +/** Prüfadresse der Plugin-Webseite (ihr health.php, das ohne Zugangsschlüssel antwortet); leer, wenn kein offizieller Katalog eingetragen ist. */ +function status_plugin_site_url(): string +{ + if (!function_exists('catalog_defaults')) return ''; + $parts = parse_url((string) (catalog_defaults()['plugins_url'] ?? '')); + if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return ''; + $path = rtrim(str_replace(chr(92), '/', dirname((string) ($parts['path'] ?? '/'))), '/'); + return 'https://' . $parts['host'] . (!empty($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/health.php'; +} + function status_component_monitor_target(PDO $pdo, array $component): ?array { $type = (string) ($component['monitor_type'] ?? 'auto'); @@ -55,6 +65,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array if (str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server']; return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse']; } + if (str_contains($name, 'plugin') && (str_contains($name, 'webseite') || str_contains($name, 'website') || str_contains($name, 'seite') || str_contains($name, 'katalog'))) { + $url = status_plugin_site_url(); + return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null; + } if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung']; if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung']; if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank']; @@ -64,6 +78,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array if ((str_contains($name, 'push') || str_contains($category, 'benachrichtigung')) && function_exists('fcm_configured') && fcm_configured()) return ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst']; return null; } + if ($type === 'plugin_site') { + $url = status_plugin_site_url(); + return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null; + } if ($type === 'database') return ['type' => 'database', 'target' => '', 'label' => 'Datenbank']; if ($type === 'android_app') return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung']; if ($type === 'windows_app') return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung']; @@ -129,7 +147,7 @@ function status_execute_component_check(PDO $pdo, array $check): array $ok = is_resource($socket); if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar'; } else $error = 'Ungültiges Prüfziel'; - } elseif ($check['type'] === 'http') { + } elseif (in_array($check['type'], ['http', 'plugin_site'], true)) { $url = (string) $check['target']; if (filter_var($url, FILTER_VALIDATE_URL) && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) { if (function_exists('curl_init')) { @@ -210,9 +228,40 @@ function status_incident_states(): array ]; } -function status_components(PDO $pdo, bool $includeDisabled = false): array +/** Gibt es die Spalte is_internal schon (Datenbank-Update eingespielt)? Bis dahin verhält sich alles wie bisher. */ +function status_has_internal(PDO $pdo): bool { - $where = $includeDisabled ? '' : 'WHERE COALESCE(c.is_enabled, 1) = 1 '; + static $cache = []; + $key = spl_object_id($pdo); + if (isset($cache[$key])) return $cache[$key]; + try { + $rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll(); + foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'is_internal') return $cache[$key] = true; + } catch (Throwable $e) { + // Tabelle fehlt: nichts zu filtern + } + return $cache[$key] = false; +} + +/** SQL-Zusatz: nur öffentliche (nicht interne) Dienste; $alias ist der Tabellenname von status_components. */ +function status_public_sql(PDO $pdo, string $alias = 'c'): string +{ + return status_has_internal($pdo) ? " AND COALESCE({$alias}.is_internal, 0) = 0" : ''; +} + +/** Nicht öffentlich sichtbar, aber geprüft: Dienst als „intern“ markieren oder die Markierung entfernen. */ +function status_set_component_internal(PDO $pdo, int $id, bool $internal): bool +{ + if (!status_has_internal($pdo)) return false; + $pdo->prepare('UPDATE status_components SET is_internal = ? WHERE id = ?')->execute([$internal ? 1 : 0, $id]); + return (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn(); +} + +/** @param bool $publicOnly nur Dienste, die auf der öffentlichen Statusseite stehen dürfen (nicht intern) */ +function status_components(PDO $pdo, bool $includeDisabled = false, bool $publicOnly = false): array +{ + $where = $includeDisabled ? 'WHERE 1 = 1 ' : 'WHERE COALESCE(c.is_enabled, 1) = 1 '; + if ($publicOnly) $where .= status_public_sql($pdo) . ' '; return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id {$where}ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll(); } @@ -232,6 +281,7 @@ function status_monitor_types(): array 'smtp' => 'SMTP-Server', 'mail' => 'E-Mail (SMTP und IMAP)', 'fcm' => 'Push-Dienst', + 'plugin_site' => 'Plugin-Webseite (offizieller Katalog)', 'tcp' => 'Server und Port', 'manual' => 'Manuell verwalten', ]; @@ -413,16 +463,16 @@ function status_overall(PDO $pdo): array { $states = status_component_states(); $worst = 'operational'; - foreach (status_components($pdo) as $c) { + foreach (status_components($pdo, false, true) as $c) { if (($states[$c['status']][2] ?? 0) > $states[$worst][2]) $worst = $c['status']; } // Eine offene Störung ohne zugeordnete Komponente zählt mindestens als „eingeschränkt“ - $open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)")->fetchColumn(); + $open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1" . status_public_sql($pdo) . "))")->fetchColumn(); if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded'; $activeMaintenance = status_active_maintenance_map($pdo); $hasActiveMaintenance = isset($activeMaintenance[0]); if (!$hasActiveMaintenance) { - foreach (status_components($pdo) as $component) { + foreach (status_components($pdo, false, true) as $component) { if (isset($activeMaintenance[(int) $component['id']])) { $hasActiveMaintenance = true; break; } } } @@ -434,7 +484,7 @@ function status_overall(PDO $pdo): array function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false, bool $includeDisabledComponents = false): array { $since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400); - $componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)'; + $componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))'; $sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE " . ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . $componentFilter . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50'; $stmt = $pdo->prepare($sql); @@ -455,7 +505,7 @@ function status_maintenance_windows(PDO $pdo, bool $includeHistory = false): arr $since = date('Y-m-d H:i:s', time() - 60 * 86400); $where = $includeHistory ? 'WHERE (w.ends_at >= ? OR w.cancelled_at >= ?)' - : 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)'; + : 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))'; $stmt = $pdo->prepare("SELECT w.*, c.name AS component_name FROM status_maintenance_windows w LEFT JOIN status_components c ON c.id = w.component_id $where ORDER BY w.starts_at ASC, w.id ASC LIMIT 100"); $stmt->execute($includeHistory ? [$since, $since] : [$now]); return $stmt->fetchAll(); @@ -629,6 +679,7 @@ function status_restore_core_components(PDO $pdo): int ['Backup-Server', 'Server & Datenbank', 'tcp', ['Backup Server', 'FTP-Backup-Server']], ['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']], ['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']], + ['Plugin-Webseite', 'Server & Datenbank', 'plugin_site', ['Plugin-Website', 'Pluginseite', 'Plugin Webseite']], ['Minecraft-Server', 'Apps', 'tcp', ['Minecraft Server']], ['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']], ['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']], @@ -650,6 +701,7 @@ function status_restore_core_components(PDO $pdo): int $maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn(); $restored = 0; foreach ($defaults as [$name, $categoryName, $monitorType, $aliases]) { + if ($monitorType === 'plugin_site' && status_plugin_site_url() === '') continue; // ohne offiziellen Katalog gibt es nichts zu prüfen $existing = null; foreach (array_merge([$name], $aliases) as $candidate) { $find->execute([$candidate]); @@ -667,6 +719,7 @@ function status_restore_core_components(PDO $pdo): int if ($id) { status_set_component_monitor($pdo, $id, $monitorType, ''); if (in_array($name, ['Minecraft-Server', 'Backup-Server'], true)) status_set_component_enabled($pdo, $id, false); + if ($monitorType === 'plugin_site') status_set_component_internal($pdo, $id, true); // nur für Mitarbeiter sichtbar $maxOrder += 10; $restored++; } @@ -742,7 +795,7 @@ function status_summary(PDO $pdo): array 'overall' => $overall, 'componentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v[0]], array_keys($states), $states), 'incidentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v], array_keys(status_incident_states()), status_incident_states()), - 'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status']], status_components($pdo)), + 'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status'], 'internal' => !empty($c['is_internal'])], status_components($pdo)), 'incidents' => array_map(fn($i) => [ 'id' => (int) $i['id'], 'title' => (string) $i['title'], 'status' => (string) $i['status'], 'statusLabel' => status_incident_states()[$i['status']] ?? $i['status'], 'component' => $i['component_name'] ?? null, 'componentId' => $i['component_id'] !== null ? (int) $i['component_id'] : null, diff --git a/DeskHub-Webseite/includes/vacation.php b/DeskHub-Webseite/includes/vacation.php index 1fabd649..9542c6a0 100644 --- a/DeskHub-Webseite/includes/vacation.php +++ b/DeskHub-Webseite/includes/vacation.php @@ -49,10 +49,20 @@ function vacation_hand_over(PDO $pdo, int $staffId): int $t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id"); $t->execute([$staffId]); $tickets = $t->fetchAll(); - foreach ($tickets as $row) { - $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$to, $row['id']]); - $pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)')->execute([$row['id'], $staffId, $to, db_now()]); - log_activity($pdo, (int) $row['id'], $staffId, 'An die Vertretung ' . $toName . ' weitergegeben (' . $name . ' ist abwesend)'); + $own = !$pdo->inTransaction(); + if ($own) $pdo->beginTransaction(); // entweder alles (Zuweisung und Vermerk zur Rückgabe) oder nichts + try { + $move = $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?'); + $note = $pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)'); + foreach ($tickets as $row) { + $move->execute([$to, $row['id']]); + $note->execute([$row['id'], $staffId, $to, db_now()]); + log_activity($pdo, (int) $row['id'], $staffId, 'An die Vertretung ' . $toName . ' weitergegeben (' . $name . ' ist abwesend)'); + } + if ($own) $pdo->commit(); + } catch (Throwable $e) { + if ($own && $pdo->inTransaction()) $pdo->rollBack(); + throw $e; } if ($tickets) { $list = ''; @@ -113,9 +123,15 @@ function vacation_apply_change(PDO $pdo, int $staffId, array $before, array $aft function vacation_expand_ids(PDO $pdo, array $ids): array { $out = []; - foreach (array_unique(array_map('intval', $ids)) as $id) { - if ($id <= 0) continue; - $target = staff_is_on_vacation($id) ? vacation_target($pdo, $id) : $id; + $ids = array_values(array_filter(array_unique(array_map('intval', $ids)), fn($id) => $id > 0)); + if (!$ids) return []; + // Wer im Urlaub ist, in einer Abfrage ermitteln (statt eine je Mitarbeiter) + $inIds = implode(',', array_fill(0, count($ids), '?')); + $away = $pdo->prepare("SELECT id FROM staff WHERE is_on_vacation = 1 AND id IN ($inIds)"); + $away->execute($ids); + $awayIds = array_flip(array_map('intval', $away->fetchAll(PDO::FETCH_COLUMN))); + foreach ($ids as $id) { + $target = isset($awayIds[$id]) ? vacation_target($pdo, $id) : $id; if ($target !== null) $out[$target] = $target; } if (!$out) return [];