'Ticket erstellt', 'ticket.replied' => 'Antwort/Notiz zu einem Ticket', 'ticket.status_changed' => 'Status geändert', 'ticket.assigned' => 'Ticket zugewiesen', ]; } // ── API-Schlüssel ─────────────────────────────────────────────────────────── /** Erzeugt einen Schlüssel. Der Klartext wird nur hier zurückgegeben und nie gespeichert (nur der SHA-256-Hash). */ function api_key_create(PDO $pdo, string $name, bool $canCreate, bool $canRead, string $ipWhitelist, ?int $staffId): array { $name = trim($name); if ($name === '') throw new AdminModelException('Bitte einen Namen für den Schlüssel angeben.', 'NAME_REQUIRED'); if (!$canCreate && !$canRead) throw new AdminModelException('Der Schlüssel braucht mindestens ein Recht (Tickets anlegen oder lesen).', 'NO_PERMISSION'); $ips = []; foreach (preg_split('/[\s,;]+/', $ipWhitelist) ?: [] as $ip) { if ($ip === '') continue; if (!filter_var($ip, FILTER_VALIDATE_IP)) throw new AdminModelException("Ungültige IP-Adresse: {$ip}", 'INVALID_IP'); $ips[] = $ip; } $key = 'dh_' . bin2hex(random_bytes(24)); $pdo->prepare('INSERT INTO api_keys (name, key_hash, key_prefix, can_create_tickets, can_read_tickets, ip_whitelist, is_active, created_by, created_at) VALUES (?,?,?,?,?,?,1,?,?)') ->execute([$name, hash('sha256', $key), substr($key, 0, 9), $canCreate ? 1 : 0, $canRead ? 1 : 0, $ips ? implode(',', $ips) : null, $staffId, db_now()]); return ['id' => (int) $pdo->lastInsertId(), 'key' => $key]; } /** Prüft einen Schlüssel samt IP-Beschränkung. Gibt die Zeile zurück oder null. */ function api_key_verify(PDO $pdo, string $key, string $ip): ?array { if (!preg_match('/^dh_[a-f0-9]{48}$/', $key)) return null; $stmt = $pdo->prepare('SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1'); $stmt->execute([hash('sha256', $key)]); $row = $stmt->fetch(); if (!$row) return null; if (!empty($row['ip_whitelist']) && !in_array($ip, explode(',', $row['ip_whitelist']), true)) return null; $pdo->prepare('UPDATE api_keys SET last_used_at = ? WHERE id = ?')->execute([db_now(), $row['id']]); return $row; } // ── 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). */ /** Grenzen für Dateianhänge über die öffentliche Schnittstelle (die Datei kommt Base64-kodiert im JSON). */ const PUBLIC_ATTACHMENT_MAX_FILES = 5; const PUBLIC_ATTACHMENT_FILE_BYTES = 5 * 1048576; const PUBLIC_ATTACHMENT_TOTAL_BYTES = 10 * 1048576; /** Dürfen Besucher bei diesem Formular Dateien anhängen, und mit welchen Grenzen? (Ein eigenes Formular kann Anhänge abschalten.) */ function public_attachment_policy(?array $own): array { $enabled = $own === null || !array_key_exists('attachment_show', $own) || !empty($own['attachment_show']); return [ 'enabled' => $enabled, 'maxFiles' => PUBLIC_ATTACHMENT_MAX_FILES, 'maxFileBytes' => min(PUBLIC_ATTACHMENT_FILE_BYTES, get_max_upload_size()), 'maxTotalBytes' => PUBLIC_ATTACHMENT_TOTAL_BYTES, 'allowedExtensions' => get_allowed_extensions(), ]; } /** * Prüft die Dateianhänge einer Anfrage und liefert sie dekodiert: [['name' => …, 'data' => Rohdaten], …]. Geprüft wird alles vor dem * Anlegen des Tickets (Anzahl, Größe, Endung, Inhalt wie beim Upload im Formular). * * @throws AdminModelException mit Code INVALID_ATTACHMENT (die Meldung ist für den Besucher gedacht) */ function public_attachments_prepare($raw, ?array $own): array { if ($raw === null || $raw === [] || $raw === '') return []; $policy = public_attachment_policy($own); if (!is_array($raw) || (count($raw) > 0 && array_keys($raw) !== range(0, count($raw) - 1))) throw new AdminModelException('Die Dateianhänge haben ein ungültiges Format.', 'INVALID_ATTACHMENT'); if (!$policy['enabled']) throw new AdminModelException('Bei diesem Formular können keine Dateien angehängt werden.', 'INVALID_ATTACHMENT'); if (count($raw) > $policy['maxFiles']) throw new AdminModelException('Es können höchstens ' . $policy['maxFiles'] . ' Dateien angehängt werden.', 'INVALID_ATTACHMENT'); $out = []; $total = 0; foreach ($raw as $a) { $name = is_array($a) ? trim((string) ($a['name'] ?? '')) : ''; $b64 = is_array($a) ? (string) ($a['data'] ?? '') : ''; if ($name === '' || mb_strlen($name) > 200 || $b64 === '') throw new AdminModelException('Jeder Anhang braucht einen Namen und Inhalt (Base64).', 'INVALID_ATTACHMENT'); $data = base64_decode(preg_replace('/\s+/', '', $b64) ?? '', true); if ($data === false || $data === '') throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist nicht lesbar (Base64).', 'INVALID_ATTACHMENT'); if (strlen($data) > $policy['maxFileBytes']) throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist zu groß (höchstens ' . round($policy['maxFileBytes'] / 1048576, 1) . ' MB).', 'INVALID_ATTACHMENT'); $total += strlen($data); if ($total > $policy['maxTotalBytes']) throw new AdminModelException('Die Anhänge sind zusammen zu groß (höchstens ' . round($policy['maxTotalBytes'] / 1048576) . ' MB).', 'INVALID_ATTACHMENT'); // Name, Endung und Inhalt wie beim Upload im Formular prüfen, ohne etwas zu speichern $tmp = tempnam(sys_get_temp_dir(), 'dhpub'); if ($tmp === false || file_put_contents($tmp, $data) === false) throw new AdminModelException('Der Anhang konnte nicht verarbeitet werden.', 'INVALID_ATTACHMENT'); $check = handle_ticket_upload(['name' => basename(str_replace(chr(92), '/', $name)), 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, [], true); @unlink($tmp); if (isset($check['error'])) throw new AdminModelException(strip_tags((string) $check['error']), 'INVALID_ATTACHMENT'); $out[] = ['name' => $name, 'data' => $data]; } return $out; } 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']), 'attachments' => public_attachment_policy($o), ]; } /** 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. * * @return array{number:string,id:int} * @throws AdminModelException bei ungültigen Eingaben */ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts = []): array { $name = trim((string) ($in['name'] ?? '')); $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) : []; // Dateianhänge (Base64 im JSON): ohne "fields" gilt das Standardformular, das Anhänge erlaubt $attachmentList = public_attachments_prepare($in['attachments'] ?? null, $formDef['own'] ?? null); $topicId = am_int_or_null($in, 'helpTopicId'); $deptId = am_int_or_null($in, 'departmentId'); $prioId = am_int_or_null($in, 'priorityId'); 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']; $prioId = $prioId ?? (int) $topic['priority_id']; } $ruleResult = evaluate_ticket_rules($pdo, ['subject' => $subject, 'message' => $message, 'requester_email' => $email, 'requester_name' => $name, 'source' => 'api']); if ($ruleResult['reject']) throw new AdminModelException('Das Ticket wurde abgelehnt.', 'REJECTED', 403); if ($ruleResult['set_department'] !== null) $deptId = $ruleResult['set_department']; if ($ruleResult['set_priority'] !== null) $prioId = $ruleResult['set_priority']; if ($ruleResult['set_help_topic'] !== null) $topicId = $ruleResult['set_help_topic']; $deptId = active_department_id($pdo, $deptId ?: (int) get_setting('default_department_id', '1')); if (!$deptId) throw new AdminModelException('Es ist keine Abteilung eingerichtet.', 'NO_DEPARTMENT', 503); if (!$prioId) $prioId = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); $pStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); $pStmt->execute([$prioId]); $priority = $pStmt->fetch(); if (!$priority) throw new AdminModelException('Die Priorität ist ungültig.', 'INVALID_PRIORITY'); $sla = calc_sla_dates($priority, null, $deptId, $topicId); $number = generate_ticket_number($pdo); $now = db_now(); if (get_setting('ip_anonymize', '0') === '1') { $clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp); $clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp); } $pdo->prepare( 'INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, help_topic_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([$number, $subject, $name, $email, $deptId, $topicId, $prioId, 'open', 'api', $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now]); $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]); $threadId = (int) $pdo->lastInsertId(); foreach ($attachmentList as $att) { $saved = store_ticket_attachment_data($pdo, $threadId, $att['name'], $att['data']); if (!$saved['ok']) log_activity($pdo, $ticketId, null, 'Anhang nicht übernommen: ' . mb_substr($att['name'], 0, 80) . ' (' . mb_substr((string) ($saved['error'] ?? ''), 0, 100) . ')'); } 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']); $assignTo = $ruleResult['assign_staff']; if ($assignTo === null) { // Einstellung der Abteilung ("Neue Tickets automatisch zuweisen") $assignTo = department_auto_assignee($pdo, (int) $deptId); if ($assignTo !== null) log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Einstellung der Abteilung)'); } if ($assignTo !== null) { $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]); } foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName); apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']); $closedByRule = apply_rule_close($pdo, $ticketId, $ruleResult); $d = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?'); $d->execute([$deptId]); $dept = $d->fetch(); $forNotify = ['ticket_number' => $number, 'subject' => $subject, 'requester_name' => $name, 'requester_email' => $email, 'department_id' => $deptId, 'department_name' => $dept['name'] ?? null]; if (!$closedByRule) { notify_new_ticket_channels($forNotify, $dept['email'] ?? null); notify_staff_new_ticket($forNotify); notify_customer_ticket_created($forNotify); webhook_dispatch($pdo, 'ticket.created', $ticketId); } return ['number' => $number, 'id' => $ticketId]; } // ── Webhooks ──────────────────────────────────────────────────────────────── /** Prüft eine Webhook-URL: nur http(s), und der Zielhost darf nicht intern (localhost/private Netze) sein. */ function webhook_validate_url(string $url): void { $parts = parse_url($url); if (!$parts || !in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) { throw new AdminModelException('Bitte eine gültige URL mit http:// oder https:// angeben.', 'INVALID_URL'); } $ip = filter_var($parts['host'], FILTER_VALIDATE_IP) ? $parts['host'] : gethostbyname($parts['host']); if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { throw new AdminModelException('Die Adresse zeigt auf ein internes oder nicht auflösbares Ziel und ist nicht erlaubt.', 'INVALID_URL'); } } /** Legt einen Webhook an oder aktualisiert ihn. Ein leeres Secret lässt das bisherige unverändert. */ function webhook_save(PDO $pdo, int $id, array $in): int { $name = trim((string) ($in['name'] ?? '')); $url = trim((string) ($in['url'] ?? '')); $secret = (string) ($in['secret'] ?? ''); $isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : 1; $events = array_values(array_intersect(array_keys(webhook_event_labels()), array_map('strval', (array) ($in['events'] ?? [])))); if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED'); if (!$events) throw new AdminModelException('Bitte mindestens ein Ereignis wählen.', 'NO_EVENTS'); webhook_validate_url($url); $now = db_now(); if ($id === 0) { if ($secret === '') $secret = bin2hex(random_bytes(20)); $pdo->prepare('INSERT INTO webhooks (name, url, secret_enc, events, is_active, fail_count, created_at) VALUES (?,?,?,?,?,0,?)') ->execute([$name, $url, encrypt_secret($secret), implode(',', $events), $isActive, $now]); return (int) $pdo->lastInsertId(); } $sql = 'UPDATE webhooks SET name=?, url=?, events=?, is_active=?'; $params = [$name, $url, implode(',', $events), $isActive]; if ($secret !== '') { $sql .= ', secret_enc=?'; $params[] = encrypt_secret($secret); } $pdo->prepare($sql . ' WHERE id=?')->execute([...$params, $id]); return $id; } /** Baut die Ereignis-Nutzlast für ein Ticket. */ function webhook_payload(PDO $pdo, string $event, int $ticketId, array $extra = []): ?array { $stmt = $pdo->prepare( 'SELECT t.*, p.name AS priority_name, d.name AS department_name, s.name AS staff_name FROM tickets t JOIN priorities p ON p.id = t.priority_id JOIN departments d ON d.id = t.department_id LEFT JOIN staff s ON s.id = t.assigned_staff_id WHERE t.id = ?' ); $stmt->execute([$ticketId]); $t = $stmt->fetch(); if (!$t) return null; return [ 'event' => $event, 'occurredAt' => gmdate('c'), 'ticket' => [ 'number' => $t['ticket_number'], 'subject' => $t['subject'], 'status' => $t['status'], 'source' => $t['source'], 'priority' => $t['priority_name'], 'department' => $t['department_name'], 'assignedTo' => $t['staff_name'], 'requesterName' => $t['requester_name'], 'requesterEmail' => $t['requester_email'], 'createdAt' => $t['created_at'], 'updatedAt' => $t['updated_at'], ], ] + $extra; } /** Sendet einen Webhook sofort. Gibt [HTTP-Status, Fehlertext|null] zurück. */ function webhook_deliver(array $hook, array $payload): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $secret = decrypt_secret($hook['secret_enc']); $headers = [ 'Content-Type: application/json', 'User-Agent: DeskHub-Webhook/1', 'X-DeskHub-Event: ' . $payload['event'], 'X-DeskHub-Signature: sha256=' . hash_hmac('sha256', $body, $secret), ]; // Ziel beim Senden erneut prüfen und die Adresse festnageln (verhindert DNS-Rebinding: der Name zeigte beim Speichern // auf eine öffentliche, zeigt jetzt aber auf eine interne Adresse). Nur Tests dürfen an lokale Empfänger senden. $parts = parse_url((string) $hook['url']) ?: []; $host = (string) ($parts['host'] ?? ''); $pinned = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host); $testing = getenv('DESKHUB_TESTING') === '1'; if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true) || (!$testing && !filter_var($pinned, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))) { return [0, 'Ziel ist intern oder nicht auflösbar (blockiert)']; } if (!function_exists('curl_init')) { $ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body, 'timeout' => 5, 'ignore_errors' => true]]); $res = @file_get_contents($hook['url'], false, $ctx); $status = 0; foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1]; return [$status, $res === false ? 'Verbindung fehlgeschlagen' : ($status >= 200 && $status < 300 ? null : "HTTP {$status}")]; } $ch = curl_init($hook['url']); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_RESOLVE => [$host . ':' . (int) ($parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80)) . ':' . $pinned]]); curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); curl_close($ch); return [$status, $err !== '' ? $err : ($status >= 200 && $status < 300 ? null : "HTTP {$status}")]; } /** * Meldet ein Ereignis an alle aktiven Webhooks, die es abonniert haben. Die Zustellung läuft nach dem * Absenden der Antwort (Shutdown), damit sie den Aufrufer nicht aufhält. Fehler werden am Webhook vermerkt; * nach 10 Fehlern in Folge wird er automatisch deaktiviert. */ function webhook_dispatch(PDO $pdo, string $event, int $ticketId, array $extra = []): void { // Plugins informieren (läuft isoliert, Fehler in Plugins beeinflussen das Ticket nie) plugin_hook($event, ['event' => $event, 'ticketId' => $ticketId] + $extra); try { $hooks = $pdo->query('SELECT * FROM webhooks WHERE is_active = 1')->fetchAll(); $hooks = array_values(array_filter($hooks, fn($h) => in_array($event, explode(',', (string) $h['events']), true))); if (!$hooks) return; $payload = webhook_payload($pdo, $event, $ticketId, $extra); if (!$payload) return; } catch (Throwable $e) { error_log('webhook_dispatch: ' . $e->getMessage()); return; } $run = function () use ($pdo, $hooks, $payload): void { foreach ($hooks as $hook) { try { [$status, $error] = webhook_deliver($hook, $payload); if ($error === null) { $pdo->prepare('UPDATE webhooks SET last_status = ?, last_error = NULL, last_delivery_at = ?, fail_count = 0 WHERE id = ?')->execute([$status, db_now(), $hook['id']]); } else { $fails = (int) $hook['fail_count'] + 1; $pdo->prepare('UPDATE webhooks SET last_status = ?, last_error = ?, last_delivery_at = ?, fail_count = ?, is_active = ? WHERE id = ?') ->execute([$status ?: null, mb_substr($error, 0, 300), db_now(), $fails, $fails >= 10 ? 0 : 1, $hook['id']]); } } catch (Throwable $e) { error_log('webhook_deliver: ' . $e->getMessage()); } } }; if (PHP_SAPI === 'cli') { $run(); return; } // Tests/Cron: sofort register_shutdown_function(function () use ($run) { if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request(); $run(); }); }