Quelltext des aktuellen Standes
<?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;
}
}