Files
deskhub/DeskHub-Webseite/includes/integrations.php
T
2026-10-16 19:12:18 +00:00

276 lines
16 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;
}
// ── 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
{
$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, 'Ticket über die API erstellt');
if ($ruleResult['assign_staff'] !== null) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$ruleResult['assign_staff'], $ticketId]);
}
foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName);
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$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];
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();
});
}