OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

class-api-client.php

14 KB
<?php

if (!defined('ABSPATH')) {
    exit;
}

/**
 * HTTP-Client für das OpenNIT API
 *
 * Kapselt alle API-Aufrufe an das OpenNIT.
 * Authentifizierung über: Authorization: Bearer <token>
 */
class OpenNIT_ApiClient {

    private string $base_url;
    private string $token;
    private string $signing_secret;

    public function __construct() {
        $url            = rtrim((string)(defined('OPENNIT_API_URL') ? OPENNIT_API_URL : get_option('opennit_api_url', '')), '/');
        $this->base_url = preg_replace('#^http://#', 'https://', $url);
        $this->token    = (string)(defined('OPENNIT_API_TOKEN') ? OPENNIT_API_TOKEN : get_option('opennit_api_token', ''));
        $this->signing_secret = (string)(defined('OPENNIT_API_SIGNING_SECRET') ? OPENNIT_API_SIGNING_SECRET : get_option('opennit_api_signing_secret', ''));
    }

    /**
     * Generische HTTP-Anfrage
     *
     * SICHERHEIT: Jede Anfrage wird per HMAC-SHA256 signiert. Der kanonische String
     * bindet Methode, Pfad, Body und die End-Nutzer-E-Mail an das Signing-Secret und
     * enthält Timestamp + Nonce (Replay-Schutz). Muss exakt mit der Server-Verifizierung
     * (WordPressApiController::verifyRequestSignature) übereinstimmen.
     */
    private function request(string $method, string $endpoint, array $body = []): array|WP_Error {
        if (empty($this->base_url) || empty($this->token)) {
            return new WP_Error('not_configured', 'OpenNIT API nicht konfiguriert. Bitte Einstellungen prüfen.');
        }
        if (empty($this->signing_secret)) {
            return new WP_Error('not_configured', 'OpenNIT: Signing-Secret fehlt. Bitte in den Plugin-Einstellungen eintragen (wird beim Token-Erstellen im OpenNIT-Backend angezeigt).');
        }

        $url    = $this->base_url . $endpoint;
        $method = strtoupper($method);

        // Raw-Body deterministisch (identisch zur Server-Hashing-Eingabe)
        $raw_body = !empty($body) ? wp_json_encode($body) : '';

        // Signatur-relevante E-Mail: Query-Param `email`, sonst Body-Feld `email`
        $email = '';
        $query = (string) wp_parse_url($url, PHP_URL_QUERY);
        if ($query !== '') {
            parse_str($query, $qa);
            if (isset($qa['email'])) {
                $email = (string) $qa['email'];
            }
        }
        if ($email === '' && isset($body['email'])) {
            $email = (string) $body['email'];
        }

        $args = $this->sign_args($method, $url, $raw_body, $email);
        $args['headers']['Content-Type'] = 'application/json';
        $args['headers']['Accept']       = 'application/json';
        if ($raw_body !== '') {
            $args['body'] = $raw_body;
        }

        $response = wp_remote_request($url, $args);

        if (is_wp_error($response)) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);
        $data = json_decode($body, true);

        if (!is_array($data)) {
            $preview = substr(strip_tags($body), 0, 120);
            $preview = preg_replace('/\s+/', ' ', trim($preview));
            $hint    = $preview ? ' – Antwort: ' . $preview : ' (leere Antwort)';
            return new WP_Error('invalid_response',
                'Ungültige Antwort vom Server (HTTP ' . $code . ')' . $hint);
        }

        if ($code >= 400) {
            $message = $data['error'] ?? ('HTTP Fehler ' . $code);
            // Vollständige Antwort-Daten als WP_Error-Data mitgeben (z. B. conflict_from/to)
            return new WP_Error('api_error_' . $code, $message, $data);
        }

        return $data;
    }

    /**
     * Baut die signierten Request-Argumente (Header inkl. HMAC-Signatur).
     * SICHERHEIT: Der kanonische String muss exakt mit der Server-Verifizierung
     * (WordPressApiController::verifyRequestSignature) übereinstimmen:
     *   "v2\n" . METHOD . "\n" . PATH . "\n" . QUERY_SORTIERT . "\n" .
     *   EMAIL_LOWER . "\n" . sha256(RAW_BODY) . "\n" . TIMESTAMP . "\n" . NONCE
     *
     * Der Query-String gehört mit in die Signatur, damit GET-Parameter unterwegs
     * nicht austauschbar sind. Die Sortierung stellt sicher, dass beide Seiten
     * denselben Text bilden.
     */
    private function sign_args(string $method, string $url, string $raw_body, string $email): array {
        $method = strtoupper($method);
        $path   = (string) wp_parse_url($url, PHP_URL_PATH);
        $email  = strtolower(trim($email));

        $query = (string) wp_parse_url($url, PHP_URL_QUERY);
        $query_parts = [];
        parse_str($query, $query_parts);
        ksort($query_parts);
        $canonical_query = http_build_query($query_parts);

        $timestamp = (string) time();
        $nonce     = bin2hex(random_bytes(16));
        $body_hash = hash('sha256', $raw_body);

        $canonical = implode("\n", ['v2', $method, $path, $canonical_query, $email, $body_hash, $timestamp, $nonce]);
        $signature = hash_hmac('sha256', $canonical, $this->signing_secret);

        return [
            'method'  => $method,
            'timeout' => 10,
            'headers' => [
                'Authorization'      => 'Bearer ' . $this->token,
                'X-OpenNIT-Timestamp' => $timestamp,
                'X-OpenNIT-Nonce'     => $nonce,
                'X-OpenNIT-Signature' => $signature,
            ],
        ];
    }

    /**
     * Signierter Binär-GET (für Datei-/Bild-Proxy). Gibt die rohe Antwort zurück
     * (kein JSON-Decode), damit auch Bilder/PDFs durchgereicht werden können.
     *
     * @return array{status:int,body:string,content_type:string,content_disposition:string}|WP_Error
     */
    public function proxy_get(string $endpoint): array|WP_Error {
        if (empty($this->base_url) || empty($this->token)) {
            return new WP_Error('not_configured', 'OpenNIT API nicht konfiguriert.');
        }
        if (empty($this->signing_secret)) {
            return new WP_Error('not_configured', 'OpenNIT: Signing-Secret fehlt.');
        }

        $url   = $this->base_url . $endpoint;

        // Signatur-relevante E-Mail aus dem Query-Param `email` (sonst leer)
        $email = '';
        $query = (string) wp_parse_url($url, PHP_URL_QUERY);
        if ($query !== '') {
            parse_str($query, $qa);
            if (isset($qa['email'])) {
                $email = (string) $qa['email'];
            }
        }

        $args = $this->sign_args('GET', $url, '', $email);
        $args['timeout']           = 15;
        $args['headers']['Accept'] = '*/*';

        $response = wp_remote_get($url, $args);
        if (is_wp_error($response)) {
            return $response;
        }

        return [
            'status'              => (int) wp_remote_retrieve_response_code($response),
            'body'                => (string) wp_remote_retrieve_body($response),
            'content_type'        => (string) wp_remote_retrieve_header($response, 'content-type'),
            'content_disposition' => (string) wp_remote_retrieve_header($response, 'content-disposition'),
        ];
    }

    /**
     * Alle Leihgeräte abrufen (inkl. wp_requestable und occupied_until).
     * Optional nach Typ und/oder Zeitraum filtern.
     */
    public function get_loan_devices(string $type = '', string $date_from = '', string $date_to = ''): array|WP_Error {
        $params = [];
        if ($type !== '')      $params[] = 'type='      . rawurlencode($type);
        if ($date_from !== '') $params[] = 'date_from=' . rawurlencode($date_from);
        if ($date_to   !== '') $params[] = 'date_to='   . rawurlencode($date_to);
        $qs = $params ? ('?' . implode('&', $params)) : '';
        return $this->request('GET', '/loan-devices' . $qs);
    }

    /**
     * Alle vorhandenen Gerätetypen abrufen.
     */
    public function get_device_types(): array|WP_Error {
        return $this->request('GET', '/device-types');
    }

    /**
     * Belegte Zeiträume eines Geräts abrufen
     */
    public function get_loan_device_slots(int $id): array|WP_Error {
        return $this->request('GET', '/loan-devices/' . $id . '/slots');
    }

    /**
     * Neue Leihgerät-Anfrage erstellen
     */
    public function create_loan_request(array $data): array|WP_Error {
        return $this->request('POST', '/loan-requests', $data);
    }

    /**
     * Eigene aktive/reservierte Leihen abrufen
     */
    public function get_my_loans(string $email): array|WP_Error {
        return $this->request('GET', '/my-loans?email=' . rawurlencode($email));
    }

    /**
     * Eigene zugewiesene Assets abrufen – mit 2-Minuten-Transient-Cache pro User.
     */
    public function get_my_assets(string $email): array|WP_Error {
        $cache_key = 'opennit_assets_' . md5($email);
        $cached    = get_transient($cache_key);
        if ($cached !== false) {
            return $cached;
        }
        $result = $this->request('GET', '/my-assets?email=' . rawurlencode($email));
        if (!is_wp_error($result)) {
            set_transient($cache_key, $result, 2 * MINUTE_IN_SECONDS);
        }
        return $result;
    }

    /**
     * Eigene Reservierung stornieren
     */
    public function cancel_loan_request(int $id, string $email): array|WP_Error {
        return $this->request('POST', "/loan-requests/{$id}/cancel", ['email' => $email]);
    }

    /**
     * Plugin-Konfiguration (editierbare Texte) abrufen – mit 5-Minuten-Transient-Cache.
     */
    public function get_config(): array|WP_Error {
        $cached = get_transient('opennit_config_cache');
        if ($cached !== false) {
            return $cached;
        }
        $result = $this->request('GET', '/config');
        if (!is_wp_error($result)) {
            set_transient('opennit_config_cache', $result, 5 * MINUTE_IN_SECONDS);
        }
        return $result;
    }

    /**
     * Alle Ticket-Kategorien abrufen
     */
    public function get_ticket_categories(): array|WP_Error {
        return $this->request('GET', '/ticket-categories');
    }

    /**
     * Alle Ticket-Standorte abrufen
     */
    public function get_ticket_locations(): array|WP_Error {
        return $this->request('GET', '/ticket-locations');
    }

    /**
     * Neues Ticket erstellen
     */
    public function create_ticket(array $data): array|WP_Error {
        return $this->request('POST', '/tickets', $data);
    }

    /**
     * Eigene Tickets abrufen (Status-Übersicht), auf die letzten $limit Einträge begrenzt.
     */
    public function get_my_tickets(string $email, int $limit = 15): array|WP_Error {
        return $this->request('GET', '/my-tickets?email=' . rawurlencode($email) . '&limit=' . $limit);
    }

    /**
     * Ticket-Details abrufen (inkl. Antworten, Ansprechpartner, Beobachter, Asset, Anhänge)
     */
    public function get_ticket_detail(int $id, string $email): array|WP_Error {
        return $this->request('GET', '/tickets/' . $id . '?email=' . rawurlencode($email));
    }

    /**
     * Als Einsender auf eigenes Ticket antworten
     */
    public function reply_ticket(int $id, array $data): array|WP_Error {
        return $this->request('POST', '/tickets/' . $id . '/reply', $data);
    }

    /**
     * Alle freigegebenen Wiki-Topics abrufen
     */
    public function get_topics(): array|WP_Error {
        return $this->request('GET', '/topics');
    }

    /**
     * Alle Seiten eines Topics abrufen (inkl. Unterseiten)
     */
    public function get_topic_pages(int $id): array|WP_Error {
        return $this->request('GET', '/topics/' . $id . '/pages');
    }

    /**
     * Frage an den Website-KI-Assistenten senden (Scope "assistant").
     * Antwortet auf einfache PC-Fragen + freigegebene Dokumentation und kann –
     * sofern der Token den Scope "tickets" besitzt – im Dialog ein Ticket
     * vorbereiten (Rückgabe-Feld `action`).
     *
     * Der zusätzliche Kontext wird zentral im OpenNIT-Backend gepflegt und dort
     * serverseitig berücksichtigt – er muss nicht mehr mitgesendet werden.
     *
     * @param string $message Aktuelle Nutzerfrage
     * @param array  $history Bisheriger Verlauf [['role'=>'user'|'assistant','content'=>'...'], …]
     * @param string $email   E-Mail des angemeldeten Website-Nutzers (Kontext, optional)
     * @return array{answer:string,action?:array}|WP_Error
     */
    public function ask_assistant(string $message, array $history = [], string $email = ''): array|WP_Error {
        $payload = ['message' => $message];
        if (!empty($history)) {
            $payload['history'] = array_values($history);
        }
        // E-Mail immer mitsenden (auch leer): sie ist Teil der Signatur-Eingabe.
        $payload['email'] = $email;
        return $this->request('POST', '/assistant', $payload);
    }

    /**
     * Verbindung testen: prüft Erreichbarkeit und alle relevanten Scopes.
     * Gibt true zurück wenn OK, WP_Error mit konkreter Fehlermeldung bei Problemen.
     */
    public function test_connection(): bool|WP_Error {
        // Loans-Scope prüfen
        $result = $this->get_loan_devices();
        if (is_wp_error($result)) {
            return $result;
        }

        // Tickets-Scope prüfen (wird für Standorte/Kategorien-Dropdowns benötigt)
        $locs = $this->get_ticket_locations();
        if (is_wp_error($locs)) {
            $code = $locs->get_error_code();
            if ($code === 'api_error_403') {
                return new WP_Error('scope_missing', 'Scope "tickets" fehlt – Ticket-Standorte/-Kategorien können nicht geladen werden. Token-Scopes im OpenNIT-Backend prüfen.');
            }
            return $locs;
        }

        // Assets-Scope prüfen (wird für Asset-Dropdown benötigt)
        $cfg = $this->get_config();
        if (is_wp_error($cfg)) {
            $code = $cfg->get_error_code();
            if ($code === 'api_error_403') {
                return new WP_Error('scope_missing', 'Scope "config" fehlt. Token-Scopes im OpenNIT-Backend prüfen.');
            }
            return $cfg;
        }

        return true;
    }
}