getMessage()); api_error(500, 'INTERNAL_ERROR', 'Die Anfrage konnte nicht verarbeitet werden.'); }); function api_json(array $payload, int $status = 200): never { http_response_code($status); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function api_error(int $status, string $code, string $message, array $details = []): never { api_json(['error' => ['code' => $code, 'message' => $message, 'details' => $details]], $status); } function api_require_method(string ...$methods): void { if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', $methods, true)) { header('Allow: ' . implode(', ', $methods)); api_error(405, 'METHOD_NOT_ALLOWED', 'Diese HTTP-Methode wird nicht unterstützt.'); } } function api_request_body(): array { $length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); if ($length > 1024 * 1024) { api_error(413, 'PAYLOAD_TOO_LARGE', 'Die Anfrage ist zu groß.'); } $raw = file_get_contents('php://input'); if ($raw === false || trim($raw) === '') { return []; } try { $body = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); } catch (JsonException $e) { api_error(400, 'INVALID_JSON', 'Der JSON-Inhalt ist ungültig.'); } if (!is_array($body)) { api_error(400, 'INVALID_JSON', 'Ein JSON-Objekt wird erwartet.'); } return $body; } function api_require_https(): void { $https = request_is_https(); $local = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true); $allowInsecure = getenv('DESKHUB_API_ALLOW_INSECURE') === '1'; if (!$https && !$local && !$allowInsecure) { api_error(400, 'HTTPS_REQUIRED', 'Die mobile Anmeldung ist ausschließlich über HTTPS erlaubt.'); } } /** * Liest die Anmeldung der Apps. Viele Hoster (Apache mit PHP-FPM/FastCGI, z. B. Plesk) reichen den Header "Authorization" nicht an PHP weiter. * Deshalb senden die Apps denselben Wert zusätzlich als "X-DeskHub-Authorization", der überall ankommt; hier gilt der erste vorhandene. */ function api_authorization_header(): string { $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; if (trim((string) $header) === '') $header = $_SERVER['HTTP_X_DESKHUB_AUTHORIZATION'] ?? ''; if (trim((string) $header) === '' && function_exists('getallheaders')) { foreach (getallheaders() as $name => $value) { if (in_array(strtolower((string) $name), ['authorization', 'x-deskhub-authorization'], true) && trim((string) $value) !== '') { $header = $value; break; } } } return trim((string) $header); } function api_bearer_token(): ?string { $header = api_authorization_header(); if (!preg_match('/^Bearer\s+([A-Za-z0-9_-]{40,})$/i', $header, $matches)) { return null; } return $matches[1]; } function api_issue_token(PDO $pdo, int $staffId, ?string $deviceName): array { $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); $days = max(1, min(365, (int) get_setting('mobile_api_token_days', '30'))); $expiresAt = date('Y-m-d H:i:s', time() + ($days * 86400)); $deviceName = trim((string) $deviceName); if ($deviceName === '') { $deviceName = null; } else { $deviceName = mb_substr($deviceName, 0, 120); } $pdo->prepare( 'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at) VALUES (?,?,?,?,?)' )->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now()]); return ['token' => $token, 'expires_at' => $expiresAt]; } function api_require_staff(): array { $token = api_bearer_token(); if ($token === null) { api_error(401, 'UNAUTHENTICATED', 'Ein gültiges Bearer-Token ist erforderlich.'); } $pdo = get_db(); $stmt = $pdo->prepare( 'SELECT s.*, mt.id AS mobile_token_id, mt.last_used_at AS mobile_token_last_used_at FROM mobile_api_tokens mt JOIN staff s ON s.id = mt.staff_id WHERE mt.token_hash = ? AND mt.revoked_at IS NULL AND mt.expires_at > ? AND s.is_active = 1 LIMIT 1' ); $stmt->execute([hash('sha256', $token), db_now()]); $staff = $stmt->fetch(); if (!$staff) { api_error(401, 'TOKEN_EXPIRED', 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.'); } $lastUsed = $staff['mobile_token_last_used_at'] ?? null; if (!$lastUsed || strtotime($lastUsed) < time() - 300) { $pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?') ->execute([db_now(), $staff['mobile_token_id']]); } // "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php): // auch App-Nutzung hält die automatischen Hintergrund-Jobs am Laufen. pseudo_cron_schedule_background(); return $staff; } function api_staff_payload(array $staff): array { $permissionNames = array_merge([ 'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket', 'create_ticket', 'view_reports', ], array_keys(admin_section_permissions())); $permissions = []; foreach ($permissionNames as $permission) { $permissions[$permission] = has_permission($staff, $permission); } return [ 'id' => (int) $staff['id'], 'name' => $staff['name'], 'email' => $staff['email'], 'role' => $staff['role'], 'permissions' => $permissions, ]; } /** $departmentId: Abteilung des Tickets – dort kann eine eigene Rolle (erweiterter Zugriff) gelten. */ function api_require_permission(array $staff, string $permission, ?int $departmentId = null): void { if (!has_permission($staff, $permission, $departmentId)) { api_error(403, 'FORBIDDEN', 'Für diese Aktion fehlt die erforderliche Berechtigung.'); } } function api_require_admin(array $staff): void { if ($staff['role'] !== 'admin') { api_error(403, 'FORBIDDEN', 'Diese Funktion ist nur für Administratoren verfügbar.'); } } /** Wie api_require_admin(), lässt aber Agenten mit passend zugewiesener Co-Admin-Berechtigung durch. */ function api_require_admin_or_permission(array $staff, string $permission): void { if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) { api_error(403, 'FORBIDDEN', 'Für diese Funktion fehlt die erforderliche Berechtigung.'); } } function api_find_ticket(PDO $pdo, array $staff, string $number): array { $stmt = $pdo->prepare( 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, 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.ticket_number = ?' ); $stmt->execute([$number]); $ticket = $stmt->fetch(); if (!$ticket) { api_error(404, 'TICKET_NOT_FOUND', 'Das Ticket wurde nicht gefunden.'); } $departmentIds = staff_department_ids($staff); if ($departmentIds !== null && !in_array((int) $ticket['department_id'], $departmentIds, true)) { api_error(403, 'TICKET_FORBIDDEN', 'Auf dieses Ticket besteht kein Zugriff.'); } return $ticket; } /** Adresse des ersten Bildes in einem HTML-Text (absolut, nur http/https); leer, wenn keines vorhanden ist. */ function api_first_image_url(?string $html): string { if (!preg_match('/]*\bsrc\s*=\s*(["\'])(.*?)\1/is', (string) $html, $m)) return ''; $src = trim(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8')); if (str_starts_with($src, '//')) $src = 'https:' . $src; elseif (str_starts_with($src, '/')) $src = base_url() . $src; return preg_match('#^https?://#i', $src) ? $src : ''; } function api_plain_text(?string $html): string { $text = preg_replace('//i', "\n", (string) $html); $text = preg_replace('/<\/p\s*>/i', "\n\n", $text); // Weitere Blöcke (div, Listen, Tabellenzeilen, Überschriften …) enden ebenfalls mit einem Zeilenumbruch, Listenpunkte bekommen einen Punkt $text = preg_replace('/]*>/i', '• ', $text); $text = preg_replace('/<\/(div|li|tr|h[1-6]|blockquote|pre|ul|ol|table)\s*>/i', "\n", $text); $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = preg_replace('/[ \t]+\n/', "\n", $text); // Leerzeichen am Zeilenende $text = preg_replace('/\n{3,}/', "\n\n", $text); // höchstens eine Leerzeile return trim($text); } function api_ticket_summary(array $ticket): array { return [ 'id' => (int) $ticket['id'], 'number' => $ticket['ticket_number'], 'subject' => $ticket['subject'], 'requesterName' => $ticket['requester_name'], 'requesterEmail' => $ticket['requester_email'], 'status' => $ticket['status'], 'priority' => [ 'id' => (int) $ticket['priority_id'], 'name' => $ticket['priority_name'], 'color' => $ticket['priority_color'], ], 'department' => [ 'id' => (int) $ticket['department_id'], 'name' => $ticket['department_name'], ], 'assignedStaff' => $ticket['assigned_staff_id'] === null ? null : [ 'id' => (int) $ticket['assigned_staff_id'], 'name' => $ticket['staff_name'], ], 'isOverdue' => !empty($ticket['resolution_due_at']) && strtotime($ticket['resolution_due_at']) < time() && !in_array($ticket['status'], ['resolved', 'closed'], true), // Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min // (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im // Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus // den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format). 'dueLabel' => !empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null, 'isSpam' => !empty($ticket['is_spam']), 'createdAt' => $ticket['created_at'], 'updatedAt' => $ticket['updated_at'], ]; } function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int { $permission = $type === 'note' ? 'post_note' : 'reply_ticket'; api_require_permission($staff, $permission, (int) $ticket['department_id']); if (!in_array($type, ['reply', 'note'], true)) { api_error(422, 'INVALID_MESSAGE_TYPE', 'Der Nachrichtentyp ist ungültig.'); } $message = sanitize_staff_html(resolve_canned_vars(trim($rawMessage), $ticket, $staff)); if (trim(strip_tags($message)) === '') { api_error(422, 'MESSAGE_REQUIRED', 'Bitte geben Sie eine Nachricht ein.'); } if (strlen($message) > 100000) { api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.'); } $isNote = $type === 'note'; // Live-Chat-Sperre (siehe includes/functions.php): eine öffentliche Antwort auf // einen Chat, der gerade frisch von einem anderen Mitarbeiter (egal ob Web oder // App) "besessen" wird, wird abgelehnt – interne Notizen bleiben immer erlaubt, // die sind ja ohnehin nur für Mitarbeiter sichtbar und kein Konflikt für den Kunden. if (!$isNote && $ticket['source'] === 'chat') { $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo); if ($lockedByOtherName) { api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen."); } } // Generelle Ticket-Sperre für alle anderen Tickets (siehe ticket.php – wer die GET- // Anfrage erfolgreich gemacht hat, besitzt die Sperre bereits; das hier fängt nur // den seltenen Fall ab, dass sie zwischen Laden und Absenden abgelaufen ist bzw. ein // anderer Mitarbeiter sie in der Zwischenzeit übernommen hat). if ($ticket['source'] !== 'chat') { $lockedByOtherName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo); if ($lockedByOtherName) { api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByOtherName\" bearbeitet."); } } $newStatus = $setStatus ?: 'answered'; if (!$isNote) { if (!in_array($newStatus, MOBILE_API_STATUSES, true)) { api_error(422, 'INVALID_STATUS', 'Der Zielstatus ist ungültig.'); } if (in_array($newStatus, ['resolved', 'closed'], true)) { api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']); } } $now = db_now(); $pdo->beginTransaction(); try { $stmt = $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)' ); $stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote ? 1 : 0, $now]); $threadId = (int) $pdo->lastInsertId(); if ($isNote) { save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Interne Notiz hinzugefügt (Mobile App)'); } else { $firstResponse = empty($ticket['first_responded_at']); $autoAssign = empty($ticket['assigned_staff_id']); $sql = 'UPDATE tickets SET status = ?, updated_at = ?' . ($firstResponse ? ', first_responded_at = ?' : '') . ($autoAssign ? ', assigned_staff_id = ?' : '') . ' WHERE id = ?'; $args = [$newStatus, $now]; if ($firstResponse) $args[] = $now; if ($autoAssign) $args[] = $staff['id']; $args[] = $ticket['id']; $pdo->prepare($sql)->execute($args); save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)'); } $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) $pdo->rollBack(); throw $e; } if (!$isNote) { // Signatur: optional vom Aufrufer gewählt (own, dept, none), sonst eigene, ersatzweise die der Abteilung $signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $signatureChoice); $collabStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?'); $collabStmt->execute([$ticket['id']]); notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN)); } webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => $isNote]); return $threadId; }