Files
deskhub/DeskHub-Webseite/includes/integrations.php
T
2026-11-01 11:15:37 +00:00

444 lines
25 KiB
PHP

<?php
/**
* Schnittstellen für Fremdsysteme:
* - API-Schlüssel für die öffentliche Ticket-API (api/public/v1/tickets.php)
* - Webhooks: DeskHub meldet Ereignisse (Ticket erstellt/beantwortet/Status/Zuweisung) per HTTP-POST an eine URL.
*/
/** Alle Ereignisse, die Webhooks abonnieren können. */
function webhook_event_labels(): array
{
return [
'ticket.created' => '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). */
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<int,string> 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) : [];
$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]);
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();
});
}