286 lines
17 KiB
PHP
286 lines
17 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 $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();
|
|
});
|
|
}
|