": "Wert", … } die Angaben dieses Formulars entgegen (Pflichtfelder werden geprüft). * * Authentifizierung: Header "X-API-Key: dh_…" oder "Authorization: Bearer dh_…". * Schlüssel verwaltet ein Administrator unter Verwalten → API-Schlüssel. */ require_once __DIR__ . '/../../../includes/bootstrap.php'; header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); function public_api_fail(int $status, string $code, string $message): never { http_response_code($status); echo json_encode(['error' => ['code' => $code, 'message' => $message]], JSON_UNESCAPED_UNICODE); exit; } $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; if (!in_array($method, ['GET', 'POST'], true)) { header('Allow: GET, POST'); public_api_fail(405, 'METHOD_NOT_ALLOWED', 'Erlaubt sind GET und POST.'); } $key = trim((string) ($_SERVER['HTTP_X_API_KEY'] ?? '')); if ($key === '' && preg_match('/^Bearer\s+(\S+)$/i', (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''), $m)) $key = $m[1]; $pdo = get_db(); $apiKey = $key !== '' ? api_key_verify($pdo, $key, client_ip()) : null; if (!$apiKey) { if ($key !== '' && api_key_ip_blocked($pdo, $key, client_ip())) { public_api_fail(401, 'IP_NOT_ALLOWED', 'Der Schlüssel ist gültig, aber für die IP-Adresse ' . client_ip() . ' nicht freigegeben. Diese Adresse bei „Erlaubte IPs“ des Schlüssels eintragen (oder das Feld leer lassen).'); } public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).'); } if ($method === 'POST') { if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.'); $raw = file_get_contents('php://input') ?: ''; // Normale Anfragen höchstens 200 KB; mit Dateianhängen (Base64) mehr: bis zu 10 MB Dateien, Base64 macht rund ein Drittel mehr daraus if (strlen($raw) > 200000 && (strlen($raw) > 200000 + (int) (PUBLIC_ATTACHMENT_TOTAL_BYTES * 1.4) || !str_contains($raw, '"attachments"'))) { public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.'); } $body = json_decode($raw, true); if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.'); if (($body['action'] ?? '') === 'resolve') { // Ein Fremdsystem meldet Entwarnung (zum Beispiel Minecraft-Server wieder erreichbar): Ticket auf „Gelöst“ setzen. // Nur Tickets, die über die API angelegt wurden, und nur mit der E-Mail-Adresse des Anfragenden. $number = trim((string) ($body['number'] ?? '')); $email = strtolower(trim((string) ($body['email'] ?? ''))); if ($number === '' || $email === '') public_api_fail(422, 'MISSING_PARAMS', 'Die Felder number und email sind erforderlich.'); $stmt = $pdo->prepare("SELECT * FROM tickets WHERE ticket_number = ? AND LOWER(requester_email) = ? AND source = 'api'"); $stmt->execute([$number, $email]); $ticket = $stmt->fetch(); if (!$ticket) public_api_fail(404, 'NOT_FOUND', 'Ticket nicht gefunden.'); $note = trim(strip_tags((string) ($body['message'] ?? ''))); if ($note === '') $note = 'Das Fremdsystem meldet Entwarnung.'; $note = mb_substr($note, 0, 2000); if (!in_array($ticket['status'], ['resolved', 'closed'], true)) { $now = db_now(); $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticket['id'], $note, $now]); $pdo->prepare("UPDATE tickets SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticket['id']]); log_activity($pdo, (int) $ticket['id'], null, 'Über die API gelöst: ' . $note); try { webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => (string) $ticket['status']]); } catch (Throwable $e) { /* nachrangig */ } } echo json_encode(['number' => $number, 'status' => 'resolved'], JSON_UNESCAPED_UNICODE); exit; } // Schutz vor Überflutung: höchstens 60 über die API angelegte Tickets pro Minute $recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?"); $recent->execute([date('Y-m-d H:i:s', time() - 60)]); if ((int) $recent->fetchColumn() >= 60) public_api_fail(429, 'RATE_LIMITED', 'Zu viele Anfragen. Bitte in einer Minute erneut versuchen.'); try { $created = public_ticket_create($pdo, $body, client_ip()); } catch (AdminModelException $e) { public_api_fail($e->httpStatus(), $e->errorCode(), $e->getMessage()); } http_response_code(201); echo json_encode(['number' => $created['number'], 'status' => 'open'], JSON_UNESCAPED_UNICODE); exit; } // GET ?topics=1 (Hilfethemen) und GET ?form=1&helpTopicId=…|departmentId=… (Kundenformular mit eigenen Feldern) für Website-Formulare. // Das ist dasselbe, was Kunden im Kundenportal sehen; es braucht das Recht "Tickets anlegen". if (isset($_GET['topics']) || isset($_GET['form'])) { if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.'); if (isset($_GET['topics'])) { echo json_encode(['topics' => array_map(fn(array $t) => [ 'id' => (int) $t['id'], 'name' => (string) $t['name'], 'path' => (string) $t['path'], 'depth' => (int) $t['depth'], 'parentId' => !empty($t['parent_id']) ? (int) $t['parent_id'] : null, 'departmentId' => (int) $t['department_id'], ], help_topic_options($pdo, true))], JSON_UNESCAPED_UNICODE); exit; } try { $topicId = am_int_or_null($_GET, 'helpTopicId'); $deptId = am_int_or_null($_GET, 'departmentId'); $def = public_form_definition($pdo, $topicId, $topicId === null ? ($deptId ?: (int) get_setting('default_department_id', '1')) : $deptId); } catch (AdminModelException $e) { public_api_fail($e->httpStatus(), $e->errorCode(), $e->getMessage()); } echo json_encode(['form' => public_form_payload($def)], JSON_UNESCAPED_UNICODE); exit; } // GET: Status eines Tickets (nur mit passender E-Mail-Adresse des Anfragenden) if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.'); $number = trim((string) ($_GET['number'] ?? '')); $email = strtolower(trim((string) ($_GET['email'] ?? ''))); if ($number === '' || $email === '') public_api_fail(422, 'MISSING_PARAMS', 'Die Parameter number und email sind erforderlich.'); $stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ? AND LOWER(requester_email) = ?'); $stmt->execute([$number, $email]); $ticket = $stmt->fetch(); if (!$ticket) public_api_fail(404, 'NOT_FOUND', 'Ticket nicht gefunden.'); $threads = $pdo->prepare('SELECT author_type, message, created_at FROM ticket_threads WHERE ticket_id = ? AND is_internal_note = 0 ORDER BY created_at, id'); $threads->execute([$ticket['id']]); echo json_encode(['ticket' => [ 'number' => $ticket['ticket_number'], 'subject' => $ticket['subject'], 'status' => $ticket['status'], 'createdAt' => $ticket['created_at'], 'updatedAt' => $ticket['updated_at'], 'messages' => array_map(fn($t) => ['author' => $t['author_type'] === 'customer' ? 'customer' : 'staff', 'message' => trim(strip_tags((string) $t['message'])), 'createdAt' => $t['created_at']], $threads->fetchAll()), ]], JSON_UNESCAPED_UNICODE);