'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; } // ── 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'] ?? '')); 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); $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]); 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(); }); }