OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

opennit-connector.php

20 KB
<?php
/**
 * Plugin Name:       OpenNIT | Connector
 * Plugin URI:        https://opennit.de
 * Description:       OpenNIT Connector integrates your WordPress intranet with the OpenNIT IT documentation platform. Provides employees with self-service access to loan device management, personal asset overviews, and loan requests — directly within WordPress.
 * Version:           1.1.1
 * Requires at least: 5.8
 * Requires PHP:      8.0
 * Author:            OpenNIT
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       opennit-connector
 *
 * Copyright (C) 2025  Friederich Loheide
 *
 * Dieses Plugin ist freie Software: Du kannst es unter den Bedingungen der GNU
 * General Public License, Version 2 oder (nach deiner Wahl) jeder späteren
 * Version, weitergeben und/oder verändern. Der vollständige Lizenztext liegt
 * als LICENSE neben dieser Datei.
 *
 * Die Weitergabe erfolgt in der Hoffnung, dass sie nützlich ist, jedoch OHNE
 * JEDE GEWÄHRLEISTUNG – sogar ohne die implizite Gewährleistung der Marktreife
 * oder der Eignung für einen bestimmten Zweck.
 *
 * Das Plugin ist ein eigenständiges Werk: Es kommuniziert ausschließlich über
 * HTTP mit einer OpenNIT-Installation und bindet keinen Programmcode des unter
 * AGPL-3.0-only stehenden Servers ein. Die abweichende Lizenz ist Voraussetzung
 * für eine Verteilung über WordPress.org.
 */

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

define('OPENNIT_VERSION', '1.1.1');
define('OPENNIT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('OPENNIT_PLUGIN_URL', plugin_dir_url(__FILE__));

// Rückwärtskompatibilität: Sind in der wp-config.php noch die alten Konstanten
// (IT_DOKU_API_URL/-TOKEN/-SIGNING_SECRET) gesetzt, die neuen OPENNIT_*-Konstanten
// daraus ableiten – so brechen vorhandene wp-config-Overrides nach dem Rename nicht.
foreach (['API_URL', 'API_TOKEN', 'API_SIGNING_SECRET'] as $legacyConst) {
    if (!defined('OPENNIT_' . $legacyConst) && defined('IT_DOKU_' . $legacyConst)) {
        define('OPENNIT_' . $legacyConst, constant('IT_DOKU_' . $legacyConst));
    }
}

// Klassen laden
require_once OPENNIT_PLUGIN_DIR . 'includes/class-api-client.php';
require_once OPENNIT_PLUGIN_DIR . 'includes/class-wiki.php';
require_once OPENNIT_PLUGIN_DIR . 'includes/class-shortcodes.php';
require_once OPENNIT_PLUGIN_DIR . 'includes/class-assistant.php';
require_once OPENNIT_PLUGIN_DIR . 'includes/class-admin-settings.php';

// Plugin initialisieren
add_action('init', 'opennit_init');
function opennit_init(): void {
    $shortcodes = new OpenNIT_Shortcodes();
    $shortcodes->register();

    $assistant = new OpenNIT_Assistant();
    $assistant->register();
}

// Admin-Einstellungen
if (is_admin()) {
    $settings = new OpenNIT_AdminSettings();
    $settings->register();
}

// Styles und Scripts einbinden – nur auf Seiten mit mindestens einem opennit_*-Shortcode
add_action('wp_enqueue_scripts', 'opennit_enqueue_assets');
function opennit_enqueue_assets(): void {
    global $post;
    if (!is_a($post, 'WP_Post')) {
        return;
    }
    $shortcodes = ['opennit_loan_devices', 'opennit_assets', 'opennit_ticket_form', 'opennit_my_tickets', 'opennit_wiki'];
    $has_sc = false;
    foreach ($shortcodes as $sc) {
        if (has_shortcode($post->post_content, $sc)) {
            $has_sc = true;
            break;
        }
    }
    if (!$has_sc) {
        return;
    }

    wp_enqueue_style('opennit-connector', OPENNIT_PLUGIN_URL . 'assets/style.css', [], OPENNIT_VERSION);

    // Externes JS statt inline-Script – damit Elementor / Autoptimize / WP Rocket
    // es nicht filtern oder blockieren. Beide Files werden im Footer geladen,
    // damit der DOM beim Ausführen bereits vollständig gerendert ist.
    if (has_shortcode($post->post_content, 'opennit_ticket_form')) {
        wp_enqueue_script(
            'opennit-ticket-form',
            OPENNIT_PLUGIN_URL . 'assets/js/ticket-form.js',
            [],
            OPENNIT_VERSION,
            true
        );
    }
    if (has_shortcode($post->post_content, 'opennit_loan_devices')) {
        wp_enqueue_script(
            'opennit-loan-devices',
            OPENNIT_PLUGIN_URL . 'assets/js/loan-devices.js',
            [],
            OPENNIT_VERSION,
            true
        );
    }
}

// Upgrade-Routine: läuft einmal pro Versionswechsel.
// Migriert u. a. die gespeicherte API-Basis-URL vom alten Pfad /api/wp auf /api/ex
// (OpenNIT hat den WordPress-API-Präfix umbenannt). So funktioniert die
// Verbindung nach dem Plugin-Update ohne manuelles Anpassen der URL weiter.
add_action('plugins_loaded', 'opennit_connector_maybe_upgrade');
function opennit_connector_maybe_upgrade(): void {
    $stored = (string) get_option('opennit_connector_version', '');
    if ($stored === OPENNIT_VERSION) {
        return;
    }

    // Einmalige Migration alter Option-Keys (it_doku_* → opennit_*) aus früheren
    // Plugin-Versionen. Kopiert nur, wenn der neue Key noch nicht existiert, und
    // räumt den alten anschließend auf. So bleiben API-Token, Signatur-Secret und
    // URL nach dem Update erhalten, ohne dass etwas neu eingegeben werden muss.
    $optionMap = [
        'it_doku_api_url'            => 'opennit_api_url',
        'it_doku_api_token'          => 'opennit_api_token',
        'it_doku_api_signing_secret' => 'opennit_api_signing_secret',
        'it_doku_settings'           => 'opennit_settings',
        'it_doku_menu_name'          => 'opennit_menu_name',
        'it_doku_ticket_page_url'    => 'opennit_ticket_page_url',
    ];
    foreach ($optionMap as $old => $new) {
        $oldVal = get_option($old, null);
        if ($oldVal !== null && get_option($new, null) === null) {
            update_option($new, $oldVal);
            delete_option($old);
        }
    }
    delete_transient('it_doku_config_cache');
    delete_transient('it_doku_menu_name_synced');

    // API-Basis-URL: alter Pfad /api/wp → /api/ex (auf dem ggf. migrierten Key).
    $url = (string) get_option('opennit_api_url', '');
    if ($url !== '') {
        $migrated = preg_replace('#/api/wp/?$#', '/api/ex', $url);
        if ($migrated !== null && $migrated !== $url) {
            update_option('opennit_api_url', $migrated);
            delete_transient('opennit_config_cache');
        }
    }

    update_option('opennit_connector_version', OPENNIT_VERSION);
}

// Transient-Cache bei Änderung der API-Einstellungen invalidieren
add_action('update_option_opennit_api_url',   'opennit_flush_config_cache');
add_action('update_option_opennit_api_token', 'opennit_flush_config_cache');
function opennit_flush_config_cache(): void {
    delete_transient('opennit_config_cache');
}

// Rate-Limiting Hilfsfunktion (Transient-basiert, max. $max Anfragen pro Minute pro User+Aktion)
function opennit_rate_limit(string $action, int $max = 10): bool {
    $uid = get_current_user_id();
    $key = 'opennit_rl_' . $action . '_' . $uid;
    $count = (int)get_transient($key);
    if ($count >= $max) {
        return false;
    }
    set_transient($key, $count + 1, MINUTE_IN_SECONDS);
    return true;
}

// AJAX-Handler: Leihgerät anfragen
add_action('wp_ajax_opennit_loan_request', 'opennit_handle_loan_request');
function opennit_handle_loan_request(): void {
    check_ajax_referer('opennit_nonce', 'nonce');

    if (!is_user_logged_in()) {
        wp_send_json_error('Nicht eingeloggt.', 403);
        return;
    }

    if (!opennit_rate_limit('loan_request', 10)) {
        wp_send_json_error('Zu viele Anfragen. Bitte warten Sie einen Moment.');
        return;
    }

    $user = wp_get_current_user();

    $device_id    = isset($_POST['device_id']) ? intval($_POST['device_id']) : 0;
    $planned_start = isset($_POST['planned_start']) ? sanitize_text_field($_POST['planned_start']) : '';
    $planned_end   = isset($_POST['planned_end'])   ? sanitize_text_field($_POST['planned_end'])   : '';
    $purpose       = isset($_POST['purpose'])       ? sanitize_textarea_field($_POST['purpose'])   : '';

    if ($device_id <= 0 || empty($planned_start) || empty($planned_end)) {
        wp_send_json_error('Bitte alle Pflichtfelder ausfüllen.');
        return;
    }

    $data = [
        'device_id'      => $device_id,
        'planned_start'  => $planned_start,
        'planned_end'    => $planned_end,
        'purpose'        => $purpose,
        'borrower_name'  => $user->display_name,
        'borrower_email' => $user->user_email,
    ];

    $client = new OpenNIT_ApiClient();
    $result = $client->create_loan_request($data);

    if (is_wp_error($result)) {
        // Konflikt-Daten (conflict_from, conflict_to) aus WP_Error-Data mitgeben
        $errData = $result->get_error_data() ?: [];
        wp_send_json_error([
            'message'       => $result->get_error_message(),
            'conflict_from' => $errData['conflict_from'] ?? null,
            'conflict_to'   => $errData['conflict_to']   ?? null,
        ]);
        return;
    }

    wp_send_json_success([
        'message' => $result['message'] ?? 'Anfrage erfolgreich übermittelt.',
        // status: 'pending' = wartet auf Bestätigung, 'reserved' = direkt fest gebucht
        'status'  => $result['status'] ?? 'pending',
    ]);
}

// AJAX-Handler: Geräte nach Typ und Zeitraum filtern
add_action('wp_ajax_opennit_filter_devices', 'opennit_handle_filter_devices');
function opennit_handle_filter_devices(): void {
    check_ajax_referer('opennit_nonce', 'nonce');

    if (!is_user_logged_in()) {
        wp_send_json_error('Nicht eingeloggt.', 403);
        return;
    }

    if (!opennit_rate_limit('filter_devices', 30)) {
        wp_send_json_error('Zu viele Anfragen. Bitte warten Sie einen Moment.');
        return;
    }

    $type      = sanitize_text_field($_POST['type']      ?? '');
    $date_from = sanitize_text_field($_POST['date_from'] ?? '');
    $date_to   = sanitize_text_field($_POST['date_to']   ?? '');

    // Datumsformat prüfen
    if ($date_from && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from)) $date_from = '';
    if ($date_to   && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to))   $date_to   = '';

    $client  = new OpenNIT_ApiClient();
    $result  = $client->get_loan_devices($type, $date_from, $date_to);

    if (is_wp_error($result)) {
        wp_send_json_error($result->get_error_message());
        return;
    }

    wp_send_json_success(['devices' => $result['devices'] ?? []]);
}

// AJAX-Handler: Leihe stornieren
add_action('wp_ajax_opennit_cancel_loan', 'opennit_handle_cancel_loan');
function opennit_handle_cancel_loan(): void {
    check_ajax_referer('opennit_nonce', 'nonce');

    if (!is_user_logged_in()) {
        wp_send_json_error('Nicht eingeloggt.', 403);
        return;
    }

    if (!opennit_rate_limit('cancel_loan', 10)) {
        wp_send_json_error('Zu viele Anfragen. Bitte warten Sie einen Moment.');
        return;
    }

    $user       = wp_get_current_user();
    $lending_id = isset($_POST['lending_id']) ? intval($_POST['lending_id']) : 0;

    if ($lending_id <= 0) {
        wp_send_json_error('Ungültige Anfrage.');
        return;
    }

    $client = new OpenNIT_ApiClient();
    $result = $client->cancel_loan_request($lending_id, $user->user_email);

    if (is_wp_error($result)) {
        wp_send_json_error($result->get_error_message());
        return;
    }

    if (!empty($result['error'])) {
        wp_send_json_error($result['error']);
        return;
    }

    wp_send_json_success(['message' => 'Reservierung wurde storniert.']);
}

// AJAX-Handler: Bild-Proxy (nur eingeloggte WP-User – Token bleibt server-seitig)
add_action('wp_ajax_opennit_proxy_image', 'opennit_handle_proxy_image');
function opennit_handle_proxy_image(): void {
    if (!is_user_logged_in()) {
        status_header(403);
        exit;
    }

    $path = isset($_GET['path']) ? sanitize_text_field($_GET['path']) : '';
    // Nur einfache relative Pfade erlaubt (kein Path-Traversal)
    if (!$path || !preg_match('#^[a-z0-9_-]+/[a-zA-Z0-9_\-\.]+$#i', $path)) {
        status_header(400);
        exit;
    }

    // Signierter Proxy-Aufruf (OpenNIT verlangt HMAC-Signatur – sonst 401)
    $result = (new OpenNIT_ApiClient())->proxy_get('/image?path=' . rawurlencode($path));

    if (is_wp_error($result)) {
        status_header(502);
        exit;
    }
    if ($result['status'] !== 200) {
        status_header($result['status'] >= 400 ? $result['status'] : 502);
        exit;
    }

    $mime    = $result['content_type'] ?: 'image/jpeg';
    $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
    if (!in_array(strtolower(explode(';', $mime)[0]), $allowed, true)) {
        status_header(403);
        exit;
    }

    header('Content-Type: ' . $mime);
    header('Cache-Control: private, max-age=3600');
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
    echo $result['body'];
    exit;
}

// AJAX-Handler: Ticket erstellen
add_action('wp_ajax_opennit_submit_ticket', 'opennit_handle_submit_ticket');
function opennit_handle_submit_ticket(): void {
    check_ajax_referer('opennit_nonce', 'nonce');

    if (!is_user_logged_in()) {
        wp_send_json_error(['message' => 'Nicht eingeloggt.'], 403);
        return;
    }

    if (!opennit_rate_limit('submit_ticket', 5)) {
        wp_send_json_error(['message' => 'Zu viele Anfragen. Bitte warten Sie einen Moment.']);
        return;
    }

    $subject     = isset($_POST['subject'])     ? sanitize_text_field($_POST['subject'])     : '';
    $description = isset($_POST['description']) ? trim(sanitize_textarea_field($_POST['description'])) : '';
    $location_id = isset($_POST['location_id']) ? intval($_POST['location_id'])              : 0;
    $category_id = isset($_POST['category_id']) ? intval($_POST['category_id'])              : 0;
    $from_email  = isset($_POST['from_email'])  ? sanitize_email($_POST['from_email'])       : '';
    $from_name   = isset($_POST['from_name'])   ? sanitize_text_field($_POST['from_name'])   : '';
    $asset_id    = isset($_POST['asset_id'])    ? intval($_POST['asset_id'])                 : 0;

    // Dateianhang (base64 aus FileReader)
    $attachment_b64  = isset($_POST['attachment_b64'])  ? $_POST['attachment_b64']                         : '';
    $attachment_name = isset($_POST['attachment_name']) ? sanitize_file_name($_POST['attachment_name'])    : '';
    $attachment_mime = isset($_POST['attachment_mime']) ? sanitize_text_field($_POST['attachment_mime'])   : '';

    if (empty($subject) || empty($description) || $location_id <= 0 || $category_id <= 0) {
        wp_send_json_error(['message' => 'Bitte alle Pflichtfelder ausfüllen.']);
        return;
    }

    $data = [
        'subject'         => $subject,
        'description'     => $description,
        'location_id'     => $location_id,
        'category_id'     => $category_id,
        'from_email'      => $from_email,
        'from_name'       => $from_name,
    ];
    if ($asset_id > 0) {
        $data['asset_id'] = $asset_id;
    }
    if ($attachment_b64 !== '' && $attachment_name !== '') {
        $data['attachment_b64']  = $attachment_b64;
        $data['attachment_name'] = $attachment_name;
        $data['attachment_mime'] = $attachment_mime;
    }

    $client = new OpenNIT_ApiClient();
    $result = $client->create_ticket($data);

    if (is_wp_error($result)) {
        wp_send_json_error(['message' => $result->get_error_message()]);
        return;
    }

    if (!empty($result['error'])) {
        wp_send_json_error(['message' => $result['error']]);
        return;
    }

    wp_send_json_success(['message' => $result['message'] ?? 'Ticket erfolgreich übermittelt.']);
}

// AJAX-Handler: Ticket-Details abrufen
add_action('wp_ajax_opennit_get_ticket', 'opennit_handle_get_ticket');
function opennit_handle_get_ticket(): void {
    check_ajax_referer('opennit_nonce', 'nonce');

    if (!is_user_logged_in()) {
        wp_send_json_error('Nicht eingeloggt.', 403);
        return;
    }

    $ticket_id = isset($_POST['ticket_id']) ? intval($_POST['ticket_id']) : 0;
    if ($ticket_id <= 0) {
        wp_send_json_error('Ungültige Ticket-ID.');
        return;
    }

    $user   = wp_get_current_user();
    $client = new OpenNIT_ApiClient();
    $result = $client->get_ticket_detail($ticket_id, $user->user_email);

    if (is_wp_error($result)) {
        wp_send_json_error($result->get_error_message());
        return;
    }

    wp_send_json_success($result);
}

// AJAX-Handler: Auf Ticket antworten
add_action('wp_ajax_opennit_reply_ticket', 'opennit_handle_reply_ticket');
function opennit_handle_reply_ticket(): void {
    check_ajax_referer('opennit_nonce', 'nonce');

    if (!is_user_logged_in()) {
        wp_send_json_error('Nicht eingeloggt.', 403);
        return;
    }

    if (!opennit_rate_limit('reply_ticket', 10)) {
        wp_send_json_error('Zu viele Anfragen. Bitte warten Sie einen Moment.');
        return;
    }

    $ticket_id = isset($_POST['ticket_id']) ? intval($_POST['ticket_id'])                  : 0;
    $message   = isset($_POST['message'])   ? sanitize_textarea_field($_POST['message'])   : '';

    if ($ticket_id <= 0 || $message === '') {
        wp_send_json_error('Ungültige Anfrage.');
        return;
    }

    $user   = wp_get_current_user();
    $client = new OpenNIT_ApiClient();
    $result = $client->reply_ticket($ticket_id, [
        'email'   => $user->user_email,
        'message' => $message,
    ]);

    if (is_wp_error($result)) {
        wp_send_json_error($result->get_error_message());
        return;
    }

    if (!empty($result['error'])) {
        wp_send_json_error($result['error']);
        return;
    }

    wp_send_json_success($result);
}

// AJAX-Handler: Ticket-Anhang herunterladen (Proxy – Token bleibt server-seitig)
add_action('wp_ajax_opennit_download_attachment', 'opennit_handle_download_attachment');
function opennit_handle_download_attachment(): void {
    if (!is_user_logged_in()) {
        status_header(403);
        exit;
    }

    check_ajax_referer('opennit_nonce', 'nonce');

    $attachment_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
    if ($attachment_id <= 0) {
        status_header(400);
        exit;
    }

    $user = wp_get_current_user();

    // Signierter Proxy-Aufruf (OpenNIT verlangt HMAC-Signatur – sonst 401)
    $result = (new OpenNIT_ApiClient())->proxy_get(
        '/ticket-attachments/' . $attachment_id . '?email=' . rawurlencode($user->user_email)
    );

    if (is_wp_error($result)) {
        status_header(502);
        exit;
    }
    if ($result['status'] !== 200) {
        // Echten Status durchreichen (403 = nicht Eigentümer, 404 = nicht gefunden …)
        status_header($result['status'] >= 400 ? $result['status'] : 502);
        exit;
    }

    $content_type        = $result['content_type'] ?: 'application/octet-stream';
    $content_disposition = $result['content_disposition'] ?: 'attachment';

    header('Content-Type: ' . $content_type);
    header('Content-Disposition: ' . $content_disposition);
    header('Cache-Control: private, no-store');
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
    echo $result['body'];
    exit;
}

// AJAX-Handler: Belegte Zeiträume (Slots) eines Leihgeräts abrufen (für Kalender-Widget)
add_action('wp_ajax_opennit_get_device_slots', 'opennit_handle_get_device_slots');
function opennit_handle_get_device_slots(): void {
    check_ajax_referer('opennit_nonce', 'nonce');

    if (!is_user_logged_in()) {
        wp_send_json_error('Nicht eingeloggt.', 403);
        return;
    }

    $device_id = isset($_POST['device_id']) ? intval($_POST['device_id']) : 0;
    if ($device_id <= 0) {
        wp_send_json_error('Ungültige Geräte-ID.');
        return;
    }

    $client = new OpenNIT_ApiClient();
    $result = $client->get_loan_device_slots($device_id);

    if (is_wp_error($result)) {
        wp_send_json_error($result->get_error_message());
        return;
    }

    wp_send_json_success($result);
}