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, last_used_at) VALUES (?,?,?,?,?,?)' )->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now(), 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.'); } // Jede authentifizierte Anfrage ist ein App-Heartbeat. So bleibt der Status // auch bei regelmaessiger Nutzung mit weniger als 5 Minuten Abstand aktuell. $pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?') ->execute([db_now(), $staff['mobile_token_id']]); // Aktionen in der App (Schreib-Aufrufe) zählen als Aktivität; reines Abfragen im Hintergrund und die Push-Anmeldung nicht if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !in_array(basename((string) ($_SERVER['SCRIPT_NAME'] ?? '')), ['push_device.php', 'crash_report.php', 'logout.php'], true)) { staff_touch_activity($pdo, (int) $staff['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(); $GLOBALS['__api_staff'] = $staff; // für api_is_following() 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'], 'resolutionCode' => $ticket['resolution_code'] ?? null, 'resolutionNote' => $ticket['resolution_note'] ?? null, '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'], ], 'slaPaused' => !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']), 'slaPausedAt' => $ticket['sla_paused_at'] ?? null, 'isOverdue' => !in_array($ticket['status'], ['resolved', 'closed'], true) && (!empty($ticket['is_overdue']) || (empty($ticket['sla_paused_at']) && !empty($ticket['resolution_due_at']) && strtotime($ticket['resolution_due_at']) < time() )), // 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['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']) ? 'Pausiert' : (!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'], 'snoozedUntil' => !empty($ticket['snoozed_until']) && $ticket['snoozed_until'] > db_now() ? $ticket['snoozed_until'] : null, 'following' => api_is_following((int) $ticket['id']), ]; } /** Beobachtet der angemeldete Mitarbeiter dieses Ticket? (für die App) */ function api_is_following(int $ticketId): bool { static $mine = null; if ($mine === null) { $mine = []; try { $staff = $GLOBALS['__api_staff'] ?? null; if ($staff) { $stmt = get_db()->prepare('SELECT ticket_id FROM ticket_followers WHERE staff_id = ?'); $stmt->execute([(int) $staff['id']]); $mine = array_flip(array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN))); } } catch (Throwable $e) { } } return isset($mine[$ticketId]); } /** Cc-Empfänger eines Tickets für die Apps. */ function api_ticket_collaborators(PDO $pdo, int $ticketId): array { $s = $pdo->prepare('SELECT id, email, name FROM ticket_collaborators WHERE ticket_id = ? ORDER BY created_at, id'); $s->execute([$ticketId]); return array_map(fn($r) => ['id' => (int) $r['id'], 'email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? '')], $s->fetchAll()); } function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null, ?string $resolutionCode = null, string $resolutionNote = ''): 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' && $staff['role'] !== 'admin') { $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). // (Administratoren dürfen auch bei fremder Sperre antworten.) if ($ticket['source'] !== 'chat' && $staff['role'] !== 'admin') { $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']); if (!isset(ticket_resolution_reasons()[$resolutionCode ?? ''])) { api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.'); } } } $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']); $terminal = in_array($newStatus, ['resolved', 'closed'], true); $sql = 'UPDATE tickets SET status = ?, updated_at = ?' . ($terminal ? ', resolution_code = ?, resolution_note = ?' : '') . ($newStatus === 'resolved' && empty($ticket['resolved_at']) ? ', resolved_at = ?' : '') . ($newStatus === 'closed' && empty($ticket['closed_at']) ? ', closed_at = ?' : '') . ($firstResponse ? ', first_responded_at = ?' : '') . ($autoAssign ? ', assigned_staff_id = ?' : '') . ' WHERE id = ?'; $args = [$newStatus, $now]; if ($terminal) { $args[] = $resolutionCode; $args[] = trim($resolutionNote); } if ($newStatus === 'resolved' && empty($ticket['resolved_at'])) $args[] = $now; if ($newStatus === 'closed' && empty($ticket['closed_at'])) $args[] = $now; if ($firstResponse) $args[] = $now; if ($autoAssign) $args[] = $staff['id']; $args[] = $ticket['id']; $pdo->prepare($sql)->execute($args); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']); 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; }