'', 'gitlab_enabled' => 1, 'gitlab_base_url' => 'https://git.steinert.cc', 'gitlab_project' => 'qa/templates', 'gitlab_ref' => 'main', 'gitlab_template_path' => 'templates', 'gitlab_token' => '', 'gitlab_per_page' => 100, 'gitlab_allow_writes' => 1, 'storage_enabled' => 1, 'media_pdf_enabled' => 1, 'docbee_enabled' => 1, 'docbee_base_url' => 'https://obyte.docbee.com', 'docbee_username' => '', 'docbee_password' => '', 'docbee_lifetime' => 1440, 'docbee_refresh_lifetime' => 1, 'docbee_message_internal' => 1, 'docbee_message_hidden' => 0, 'docbee_enable_fallback_note' => 0, 'docbee_preserve_ticket_status' => 1, ]; } private function settings(): array { $stored = get_option(self::OPTION_NAME, []); return wp_parse_args(is_array($stored) ? $stored : [], self::defaults()); } private static function reports_table(): string { global $wpdb; return $wpdb->prefix . 'obyte_qa_reports'; } private static function steps_table(): string { global $wpdb; return $wpdb->prefix . 'obyte_qa_steps'; } private static function create_tables(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset_collate = $wpdb->get_charset_collate(); $reports_table = self::reports_table(); $steps_table = self::steps_table(); dbDelta(" CREATE TABLE {$reports_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, user_id BIGINT UNSIGNED NULL, module VARCHAR(255) NULL, module_version VARCHAR(100) NULL, pbx_version VARCHAR(100) NULL, olm_nummer VARCHAR(100) NULL, tester VARCHAR(255) NULL, docbee_url TEXT NULL, docbee_result_url TEXT NULL, summary VARCHAR(255) NULL, pdf_attachment_id BIGINT UNSIGNED NOT NULL DEFAULT 0, pdf_url TEXT NULL, pdf_file TEXT NULL, run_json LONGTEXT NULL, PRIMARY KEY (id), KEY module (module(120)), KEY created_at (created_at), KEY user_id (user_id) ) {$charset_collate}; "); dbDelta(" CREATE TABLE {$steps_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, report_id BIGINT UNSIGNED NOT NULL, step_index INT UNSIGNED NOT NULL DEFAULT 0, step_id VARCHAR(80) NULL, title TEXT NULL, expected TEXT NULL, status VARCHAR(20) NOT NULL DEFAULT '', required TINYINT(1) NOT NULL DEFAULT 0, comment TEXT NULL, evidence TEXT NULL, group_title VARCHAR(255) NULL, group_index INT NULL, PRIMARY KEY (id), KEY report_id (report_id), KEY status (status) ) {$charset_collate}; "); } public function register_assets(): void { wp_register_style( 'obyte-qa-tool', plugins_url('assets/css/qa-tool.css', __FILE__), [], self::VERSION ); wp_register_script( 'obyte-qa-tool-js-yaml', 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js', [], '4.1.0', true ); wp_register_script( 'obyte-qa-tool', plugins_url('assets/js/qa-tool.js', __FILE__), ['obyte-qa-tool-js-yaml'], self::VERSION, true ); } private function enqueue_frontend_assets(string $root_id): void { $settings = $this->settings(); $user = wp_get_current_user(); wp_enqueue_style('obyte-qa-tool'); wp_enqueue_style('dashicons'); wp_enqueue_script('obyte-qa-tool-js-yaml'); wp_enqueue_script('obyte-qa-tool'); wp_localize_script( 'obyte-qa-tool', 'ObyteQaTool', [ 'rootId' => $root_id, 'restUrl' => esc_url_raw(rest_url(self::REST_NAMESPACE . '/')), 'nonce' => wp_create_nonce('wp_rest'), 'tester' => $this->tester_name($user), 'defaultPbxVersion' => (string) $settings['default_pbx_version'], 'gitlabEnabled' => !empty($settings['gitlab_enabled']), 'gitlabWritesEnabled' => !empty($settings['gitlab_enabled']) && !empty($settings['gitlab_allow_writes']), 'docbeeEnabled' => !empty($settings['docbee_enabled']), 'docbeeConfigured' => $this->docbee_configured($settings), 'storageEnabled' => !empty($settings['storage_enabled']), 'mediaPdfEnabled' => !empty($settings['media_pdf_enabled']), 'logoUrl' => esc_url_raw(plugins_url('assets/img/o-byte_Logo_2024_Dark.svg', __FILE__)), 'printLogoUrl' => esc_url_raw(plugins_url('assets/img/o-byte_Logo_2024_Dark.svg', __FILE__)), 'jsPdfUrls' => [ 'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js', 'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js', ], 'autoTableUrls' => [ 'https://cdn.jsdelivr.net/npm/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js', 'https://unpkg.com/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js', ], ] ); } public function render_shortcode(array $atts = []): string { if (!is_user_logged_in()) { $login_url = wp_login_url(get_permalink()); return '
' . esc_html__('Bitte melde dich an, um das QA Tool zu nutzen.', 'obyte-qa-tool') . ' ' . esc_html__('Zum Login', 'obyte-qa-tool') . '
'; } $root_id = wp_unique_id('obyte-qa-tool-'); $this->enqueue_frontend_assets($root_id); ob_start(); ?>

Qualitätssicherung

QA System

Keine Vorlage 0 Steps Bereit

Setup

Template laden, Laufdaten setzen, Ticket verknüpfen.

GitLab: nicht geladen DocBee: unbekannt

Testschritte

Status, Kommentar und Evidenz direkt am Schritt pflegen.

Step Erwartung Status Kommentar / Evidenz
Keine Vorlage geladen.
'array', 'sanitize_callback' => [$this, 'sanitize_settings'], 'default' => self::defaults(), ] ); } public function sanitize_settings($input): array { $input = is_array($input) ? $input : []; $old = $this->settings(); $out = self::defaults(); $out['default_pbx_version'] = sanitize_text_field((string) ($input['default_pbx_version'] ?? '')); $out['gitlab_enabled'] = empty($input['gitlab_enabled']) ? 0 : 1; $out['gitlab_base_url'] = $this->base_url((string) ($input['gitlab_base_url'] ?? '')); $out['gitlab_project'] = sanitize_text_field((string) ($input['gitlab_project'] ?? '')); $out['gitlab_ref'] = sanitize_text_field((string) ($input['gitlab_ref'] ?? '')); $out['gitlab_template_path'] = trim(sanitize_text_field((string) ($input['gitlab_template_path'] ?? '')), '/'); $out['gitlab_per_page'] = min(100, max(1, absint($input['gitlab_per_page'] ?? 100))); $out['gitlab_token'] = $this->sanitize_secret_field('gitlab_token', $input, $old, 'gitlab_token_clear'); $out['gitlab_allow_writes'] = empty($input['gitlab_allow_writes']) ? 0 : 1; $out['storage_enabled'] = empty($input['storage_enabled']) ? 0 : 1; $out['media_pdf_enabled'] = empty($input['media_pdf_enabled']) ? 0 : 1; $out['docbee_enabled'] = empty($input['docbee_enabled']) ? 0 : 1; $out['docbee_base_url'] = $this->base_url((string) ($input['docbee_base_url'] ?? '')); $out['docbee_username'] = sanitize_text_field((string) ($input['docbee_username'] ?? '')); $out['docbee_password'] = $this->sanitize_secret_field('docbee_password', $input, $old, 'docbee_password_clear'); $out['docbee_lifetime'] = min(10080, max(1, absint($input['docbee_lifetime'] ?? 1440))); $out['docbee_refresh_lifetime'] = empty($input['docbee_refresh_lifetime']) ? 0 : 1; $out['docbee_message_internal'] = empty($input['docbee_message_internal']) ? 0 : 1; $out['docbee_message_hidden'] = empty($input['docbee_message_hidden']) ? 0 : 1; $out['docbee_enable_fallback_note'] = empty($input['docbee_enable_fallback_note']) ? 0 : 1; $out['docbee_preserve_ticket_status'] = empty($input['docbee_preserve_ticket_status']) ? 0 : 1; return $out; } public function render_admin_page(): void { if (!current_user_can('manage_options')) { return; } $settings = $this->settings(); $option = self::OPTION_NAME; ?>

o-Byte QA Tool

Shortcode: [obyte_qa_tool]

Allgemein

GitLab Templates

Speicherung in WordPress

DocBee

migrate_legacy_pdf_attachments(500); $rows = $wpdb->get_results("SELECT * FROM {$reports_table} ORDER BY created_at DESC LIMIT 100", ARRAY_A); ?>

o-Byte QA Reports

Die letzten 100 gespeicherten QA-Exports aus der WordPress Datenbank.

ensure_secure_report_pdf($row); $pdf_download_url = $this->create_pdf_download_url($row); ?>
ID Datum Modul Version OLM Tester Summary PDF DocBee
Noch keine QA Reports gespeichert.
PDF - DocBee Ticket -
403]); } $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash((string) $_GET['token'])) : ''; if ($token === '') { wp_die(esc_html__('PDF download link is invalid.', 'obyte-qa-tool'), '', ['response' => 403]); } $transient_key = 'oqt_pdf_' . hash('sha256', $token); $payload = get_transient($transient_key); delete_transient($transient_key); if (!is_array($payload) || (int) ($payload['user_id'] ?? 0) !== get_current_user_id()) { wp_die(esc_html__('PDF download link has expired.', 'obyte-qa-tool'), '', ['response' => 403]); } global $wpdb; $reports_table = self::reports_table(); $report_id = (int) ($payload['report_id'] ?? 0); $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$reports_table} WHERE id = %d", $report_id), ARRAY_A); if (!is_array($row) || empty($row['pdf_file'])) { wp_die(esc_html__('PDF was not found.', 'obyte-qa-tool'), '', ['response' => 404]); } $file = $this->secure_pdf_absolute_path((string) $row['pdf_file']); if ($file === '' || !is_readable($file)) { wp_die(esc_html__('PDF was not found.', 'obyte-qa-tool'), '', ['response' => 404]); } $download_name = sanitize_file_name(sprintf( 'QA-%s-%s-%s.pdf', $row['module'] ?: 'report', $row['module_version'] ?: 'version', $row['pbx_version'] ?: 'pbx' )); while (ob_get_level()) { ob_end_clean(); } nocache_headers(); header('Content-Type: application/pdf'); header('Content-Disposition: inline; filename="' . $download_name . '"'); $size = filesize($file); if ($size !== false) { header('Content-Length: ' . $size); } header('X-Content-Type-Options: nosniff'); readfile($file); exit; } private function ensure_secure_report_pdf(array $row): array { if (!empty($row['pdf_file'])) { return $row; } $attachment_id = (int) ($row['pdf_attachment_id'] ?? 0); if ($attachment_id <= 0) { return $row; } $source_file = get_attached_file($attachment_id); if (!$source_file || !is_readable($source_file)) { return $row; } $stored = $this->store_secure_pdf_file((string) $source_file, basename((string) $source_file), false); if (is_wp_error($stored)) { return $row; } global $wpdb; $reports_table = self::reports_table(); $wpdb->update( $reports_table, [ 'pdf_attachment_id' => 0, 'pdf_url' => '', 'pdf_file' => (string) $stored['file'], ], ['id' => (int) $row['id']], ['%d', '%s', '%s'], ['%d'] ); wp_delete_attachment($attachment_id, true); $row['pdf_attachment_id'] = 0; $row['pdf_url'] = ''; $row['pdf_file'] = (string) $stored['file']; return $row; } private function migrate_legacy_pdf_attachments(int $limit): void { global $wpdb; $reports_table = self::reports_table(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$reports_table} WHERE pdf_attachment_id > 0 AND (pdf_file IS NULL OR pdf_file = '') ORDER BY id DESC LIMIT %d", max(1, $limit) ), ARRAY_A ); if (!is_array($rows)) { return; } foreach ($rows as $row) { if (is_array($row)) { $this->ensure_secure_report_pdf($row); } } } private function create_pdf_download_url(array $row): string { if (empty($row['pdf_file'])) { return ''; } $file = $this->secure_pdf_absolute_path((string) $row['pdf_file']); if ($file === '' || !is_readable($file)) { return ''; } $token = wp_generate_password(40, false, false); set_transient( 'oqt_pdf_' . hash('sha256', $token), [ 'report_id' => (int) $row['id'], 'user_id' => get_current_user_id(), ], 10 * MINUTE_IN_SECONDS ); return add_query_arg( [ 'action' => 'obyte_qa_pdf', 'token' => $token, ], admin_url('admin-post.php') ); } public function register_rest_routes(): void { register_rest_route(self::REST_NAMESPACE, '/templates', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list_templates'], 'permission_callback' => [$this, 'rest_can_use'], ]); register_rest_route(self::REST_NAMESPACE, '/template', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'rest_get_template'], 'permission_callback' => [$this, 'rest_can_use'], 'args' => [ 'path' => [ 'type' => 'string', 'required' => true, ], ], ]); register_rest_route(self::REST_NAMESPACE, '/docbee/message', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_post_docbee_message'], 'permission_callback' => [$this, 'rest_can_use'], ]); register_rest_route(self::REST_NAMESPACE, '/export', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_export_run'], 'permission_callback' => [$this, 'rest_can_use'], ]); register_rest_route(self::REST_NAMESPACE, '/gitlab/template', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_push_gitlab_template'], 'permission_callback' => [$this, 'rest_can_use'], ]); register_rest_route(self::REST_NAMESPACE, '/reports', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list_reports'], 'permission_callback' => [$this, 'rest_can_use'], ]); } public function rest_can_use(): bool { return is_user_logged_in(); } public function rest_list_templates(WP_REST_Request $request) { $settings = $this->settings(); if (empty($settings['gitlab_enabled'])) { return new WP_Error('gitlab_disabled', 'GitLab templates are disabled.', ['status' => 403]); } $base_url = $settings['gitlab_base_url']; $project = $settings['gitlab_project']; if (!$base_url || !$project) { return new WP_Error('gitlab_missing_config', 'GitLab base URL or project is missing.', ['status' => 400]); } $url = $base_url . '/api/v4/projects/' . rawurlencode($project) . '/repository/tree'; $url = add_query_arg( [ 'path' => $settings['gitlab_template_path'], 'ref' => $settings['gitlab_ref'], 'per_page' => (int) $settings['gitlab_per_page'], ], $url ); $response = wp_remote_get($url, [ 'timeout' => 20, 'headers' => $this->gitlab_headers($settings), ]); $data = $this->remote_json($response, 'gitlab_tree_failed'); if (is_wp_error($data)) { return $data; } $files = []; foreach ($data as $item) { if (!is_array($item)) { continue; } $name = (string) ($item['name'] ?? ''); $path = (string) ($item['path'] ?? ''); $type = (string) ($item['type'] ?? ''); if ($type === 'blob' && preg_match('/\.ya?ml$/i', $name)) { $files[] = [ 'name' => $name, 'path' => $path, ]; } } return rest_ensure_response(['templates' => $files]); } public function rest_get_template(WP_REST_Request $request) { $settings = $this->settings(); if (empty($settings['gitlab_enabled'])) { return new WP_Error('gitlab_disabled', 'GitLab templates are disabled.', ['status' => 403]); } $path = $this->clean_gitlab_path((string) $request->get_param('path')); if (!$path) { return new WP_Error('gitlab_bad_path', 'Template path is invalid.', ['status' => 400]); } $url = $settings['gitlab_base_url'] . '/api/v4/projects/' . rawurlencode($settings['gitlab_project']) . '/repository/files/' . rawurlencode($path) . '/raw'; $url = add_query_arg(['ref' => $settings['gitlab_ref']], $url); $response = wp_remote_get($url, [ 'timeout' => 20, 'headers' => $this->gitlab_headers($settings), ]); if (is_wp_error($response)) { return new WP_Error('gitlab_raw_failed', $response->get_error_message(), ['status' => 502]); } $code = (int) wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 300) { return new WP_Error('gitlab_raw_failed', 'GitLab returned HTTP ' . $code . '.', ['status' => $code ?: 502]); } return rest_ensure_response([ 'path' => $path, 'content' => (string) wp_remote_retrieve_body($response), ]); } public function rest_post_docbee_message(WP_REST_Request $request) { $settings = $this->settings(); $params = $request->get_json_params(); $run = is_array($params) && isset($params['run']) && is_array($params['run']) ? $params['run'] : []; if (empty($run['steps']) || !is_array($run['steps'])) { return new WP_Error('docbee_bad_run', 'QA run payload is invalid.', ['status' => 400]); } $posted = $this->docbee_post_run($run, $settings); if (is_wp_error($posted)) { return $posted; } return rest_ensure_response($posted); } public function rest_export_run(WP_REST_Request $request) { $settings = $this->settings(); $run_raw = (string) ($request->get_param('run') ?? ''); $run = json_decode($run_raw, true); if (!is_array($run) || empty($run['steps']) || !is_array($run['steps'])) { return new WP_Error('qa_bad_run', 'QA run payload is invalid.', ['status' => 400]); } $docbee = null; if (!empty($settings['docbee_enabled']) && $this->docbee_configured($settings) && $this->extract_ticket_id((string) ($run['docbee_url'] ?? ''))) { $posted = $this->docbee_post_run($run, $settings); if (is_wp_error($posted)) { $docbee = [ 'ok' => false, 'message' => $posted->get_error_message(), ]; } else { $docbee = $posted; } } $pdf_file = ''; $files = $request->get_file_params(); if (!empty($settings['storage_enabled']) && !empty($settings['media_pdf_enabled']) && isset($files['pdf']) && is_array($files['pdf']) && (int) ($files['pdf']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) { $uploaded = $this->store_secure_pdf_upload($files['pdf'], $run); if (is_wp_error($uploaded)) { return $uploaded; } $pdf_file = (string) $uploaded['file']; } $report_id = 0; $summary = $this->summary_text($run); if (!empty($settings['storage_enabled'])) { $stored = $this->store_report($run, $summary, $pdf_file, $docbee); if (is_wp_error($stored)) { return $stored; } $report_id = (int) $stored; } return rest_ensure_response([ 'ok' => true, 'report_id' => $report_id, 'summary' => $summary, 'pdf_stored' => $pdf_file !== '', 'docbee' => $docbee, ]); } public function rest_push_gitlab_template(WP_REST_Request $request) { $settings = $this->settings(); if (empty($settings['gitlab_enabled']) || empty($settings['gitlab_allow_writes'])) { return new WP_Error('gitlab_writes_disabled', 'GitLab template writes are disabled.', ['status' => 403]); } if (empty($settings['gitlab_token'])) { return new WP_Error('gitlab_missing_token', 'GitLab token is missing.', ['status' => 400]); } $params = $request->get_json_params(); $template = is_array($params) && isset($params['template']) && is_array($params['template']) ? $params['template'] : []; $yaml = is_array($params) ? (string) ($params['yaml'] ?? '') : ''; $source_path_input = is_array($params) ? (string) ($params['source_path'] ?? '') : ''; $source_path = $this->clean_gitlab_path($source_path_input); if (empty($template['steps']) || !is_array($template['steps']) || $yaml === '') { return new WP_Error('gitlab_bad_template', 'Template payload is invalid.', ['status' => 400]); } if (empty($template['module']) || empty($template['module_version']) || empty($template['pbx_version'])) { return new WP_Error('gitlab_missing_template_meta', 'Module, module version, and PBX version are required for GitLab template export.', ['status' => 400]); } if (trim($source_path_input) !== '' && !$this->is_gitlab_template_file_path($source_path, $settings)) { return new WP_Error('gitlab_bad_path', 'GitLab source path is invalid.', ['status' => 400]); } $template_path = trim((string) $settings['gitlab_template_path'], '/'); if ($source_path !== '') { $path = $source_path; } else { $filename = $this->safe_filename((string) ($template['module'] ?? $template['name'] ?? 'qa-template')) . '.yaml'; $path = ($template_path !== '' ? $template_path . '/' : '') . $filename; } $file_url = $settings['gitlab_base_url'] . '/api/v4/projects/' . rawurlencode($settings['gitlab_project']) . '/repository/files/' . rawurlencode($path); $check_url = add_query_arg(['ref' => $settings['gitlab_ref']], $file_url); $check = wp_remote_get($check_url, [ 'timeout' => 20, 'headers' => $this->gitlab_headers($settings), ]); $exists = !is_wp_error($check) && (int) wp_remote_retrieve_response_code($check) >= 200 && (int) wp_remote_retrieve_response_code($check) < 300; $message = $exists ? 'Update QA template for ' . sanitize_text_field((string) $template['module']) . ' ' . sanitize_text_field((string) $template['module_version']) : 'Add QA template for ' . sanitize_text_field((string) $template['module']) . ' ' . sanitize_text_field((string) $template['module_version']); $response = wp_remote_request($file_url, [ 'method' => $exists ? 'PUT' : 'POST', 'timeout' => 20, 'headers' => array_merge($this->gitlab_headers($settings), ['Content-Type' => 'application/json']), 'body' => wp_json_encode([ 'branch' => $settings['gitlab_ref'], 'commit_message' => $message, 'content' => base64_encode($yaml), 'encoding' => 'base64', ]), ]); $data = $this->remote_json($response, 'gitlab_template_push_failed'); if (is_wp_error($data)) { return $data; } return rest_ensure_response([ 'ok' => true, 'path' => $path, 'created' => !$exists, 'result' => $data, ]); } public function rest_list_reports(WP_REST_Request $request) { global $wpdb; self::create_tables(); $reports_table = self::reports_table(); $limit = min(100, max(1, (int) ($request->get_param('limit') ?: 20))); $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$reports_table} ORDER BY created_at DESC LIMIT %d", $limit), ARRAY_A); $rows = is_array($rows) ? array_map(function ($row) { $row['has_pdf'] = !empty($row['pdf_file']) || !empty($row['pdf_attachment_id']); unset($row['pdf_attachment_id'], $row['pdf_file'], $row['pdf_url']); return $row; }, $rows) : []; return rest_ensure_response([ 'reports' => $rows, ]); } private function tester_name(WP_User $user): string { if ($user->exists()) { if ($user->display_name) { return $user->display_name; } return $user->user_login; } return ''; } private function docbee_configured(array $settings): bool { return !empty($settings['docbee_base_url']) && !empty($settings['docbee_username']) && !empty($settings['docbee_password']); } private function docbee_post_run(array $run, array $settings) { if (empty($settings['docbee_enabled'])) { return new WP_Error('docbee_disabled', 'DocBee posting is disabled.', ['status' => 403]); } if (!$this->docbee_configured($settings)) { return new WP_Error('docbee_missing_config', 'DocBee credentials are missing.', ['status' => 400]); } $ticket_id = $this->extract_ticket_id((string) ($run['docbee_url'] ?? '')); if (!$ticket_id) { return new WP_Error('docbee_missing_ticket', 'No DocBee ticket ID found in the URL.', ['status' => 400]); } $token = $this->docbee_token($settings); if (is_wp_error($token)) { return $token; } $previous_status_id = null; if (!empty($settings['docbee_preserve_ticket_status'])) { $ticket = $this->docbee_get_json($settings['docbee_base_url'] . '/restApi/v1/ticket/' . rawurlencode($ticket_id) . '?fields=ticketStatus.id,ticketStatus.name,status.id,status.name', $token); if (!is_wp_error($ticket)) { $previous_status_id = $this->extract_docbee_status_id($ticket); } } $content = $this->format_docbee_message($run); $subject = 'QA Report ' . $ticket_id; if (!empty($run['module'])) { $subject .= ' - ' . sanitize_text_field((string) $run['module']); } $message_url = $settings['docbee_base_url'] . '/restApi/v1/ticket/' . rawurlencode($ticket_id) . '/message'; $payload = [ 'content' => $content, 'subject' => $subject, 'internal' => !empty($settings['docbee_message_internal']), 'hidden' => !empty($settings['docbee_message_hidden']), ]; $message_response = $this->docbee_post_json($message_url, $payload, $token); if (!is_wp_error($message_response)) { $restored = false; if ($previous_status_id !== null) { $restored = $this->docbee_restore_ticket_status($ticket_id, $previous_status_id, $settings, $token); } return [ 'ok' => true, 'type' => 'message', 'ticket_id' => $ticket_id, 'status_restored' => $restored, 'url' => $this->docbee_result_url($settings['docbee_base_url'], $message_response, 'message'), ]; } if (empty($settings['docbee_enable_fallback_note'])) { return $message_response; } $note_url = $settings['docbee_base_url'] . '/restApi/v1/note'; $note_payload = [ 'note' => [ 'ticket' => ['id' => (int) $ticket_id], 'subject' => $subject, 'text' => $content, 'internal' => false, ], ]; $note_response = $this->docbee_post_json($note_url, $note_payload, $token); if (is_wp_error($note_response)) { return $note_response; } return [ 'ok' => true, 'type' => 'note', 'ticket_id' => $ticket_id, 'status_restored' => false, 'url' => $this->docbee_result_url($settings['docbee_base_url'], $note_response, 'note'), ]; } private function store_report(array $run, string $summary, string $pdf_file, ?array $docbee) { global $wpdb; self::create_tables(); $reports_table = self::reports_table(); $steps_table = self::steps_table(); $docbee_result_url = is_array($docbee) && !empty($docbee['url']) ? (string) $docbee['url'] : ''; $inserted = $wpdb->insert( $reports_table, [ 'created_at' => current_time('mysql'), 'user_id' => get_current_user_id(), 'module' => $this->plain($run['module'] ?? ''), 'module_version' => $this->plain($run['module_version'] ?? ''), 'pbx_version' => $this->plain($run['pbx_version'] ?? ''), 'olm_nummer' => $this->plain($run['olm_nummer'] ?? ''), 'tester' => $this->plain($run['tester'] ?? ''), 'docbee_url' => esc_url_raw((string) ($run['docbee_url'] ?? '')), 'docbee_result_url' => esc_url_raw($docbee_result_url), 'summary' => $summary, 'pdf_attachment_id' => 0, 'pdf_url' => '', 'pdf_file' => sanitize_text_field($pdf_file), 'run_json' => wp_json_encode($run), ], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s'] ); if (!$inserted) { return new WP_Error('qa_report_store_failed', $wpdb->last_error ?: 'Report could not be stored.', ['status' => 500]); } $report_id = (int) $wpdb->insert_id; $step_index = 0; $group_index = -1; $current_group = null; foreach ((array) ($run['steps'] ?? []) as $step) { if (!is_array($step)) { continue; } $kind = (string) ($step['kind'] ?? $step['type'] ?? 'step'); if ($kind === 'group') { $current_group = $this->plain($step['title'] ?? ''); $group_index++; continue; } $step_index++; $wpdb->insert( $steps_table, [ 'report_id' => $report_id, 'step_index' => $step_index, 'step_id' => $this->plain($step['id'] ?? ''), 'title' => $this->plain($step['title'] ?? ''), 'expected' => wp_strip_all_tags((string) ($step['expected'] ?? '')), 'status' => $this->normalize_status((string) ($step['status'] ?? '')), 'required' => empty($step['required']) ? 0 : 1, 'comment' => wp_strip_all_tags((string) ($step['comment'] ?? '')), 'evidence' => esc_url_raw((string) ($step['evidence'] ?? '')), 'group_title' => $current_group, 'group_index' => $group_index >= 0 ? $group_index : null, ], ['%d', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d'] ); } return $report_id; } private function store_secure_pdf_upload(array $file, array $run) { if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { return new WP_Error('qa_pdf_upload_failed', 'PDF upload is invalid.', ['status' => 400]); } if ((int) ($file['error'] ?? 0) !== UPLOAD_ERR_OK) { return new WP_Error('qa_pdf_upload_failed', 'PDF upload failed.', ['status' => 400]); } $filename = sprintf( 'QA-%s-%s-%s-%s.pdf', $this->safe_filename((string) ($run['module'] ?? 'mod')), $this->safe_filename((string) ($run['module_version'] ?? 'v')), $this->safe_filename((string) ($run['pbx_version'] ?? 'pbx')), wp_date('Ymd_His') ); return $this->store_secure_pdf_file((string) $file['tmp_name'], $filename, true); } private function store_secure_pdf_file(string $source_file, string $filename, bool $uploaded) { if (!is_readable($source_file) || !$this->looks_like_pdf($source_file)) { return new WP_Error('qa_pdf_upload_failed', 'PDF upload is invalid.', ['status' => 400]); } $target = $this->secure_pdf_target($filename); if (is_wp_error($target)) { return $target; } if ($uploaded) { $stored = move_uploaded_file($source_file, $target['path']); } else { $stored = copy($source_file, $target['path']); } if (!$stored) { return new WP_Error('qa_pdf_store_failed', 'PDF could not be stored securely.', ['status' => 500]); } @chmod($target['path'], 0640); return [ 'file' => $target['relative'], ]; } private function secure_pdf_base_dir(): string { $uploads = wp_upload_dir(null, false); if (!empty($uploads['error']) || empty($uploads['basedir'])) { return ''; } return trailingslashit((string) $uploads['basedir']) . 'obyte-qa-tool-secure'; } private function secure_pdf_absolute_path(string $relative): string { $relative = str_replace('\\', '/', trim(wp_unslash($relative))); $relative = ltrim($relative, '/'); if (strpos($relative, 'obyte-qa-tool-secure/') !== 0) { return ''; } $uploads = wp_upload_dir(null, false); if (!empty($uploads['error']) || empty($uploads['basedir'])) { return ''; } $path = trailingslashit((string) $uploads['basedir']) . $relative; $real_base = realpath($this->secure_pdf_base_dir()); $real_path = realpath($path); if (!$real_base || !$real_path) { return ''; } $real_base = trailingslashit(wp_normalize_path($real_base)); $real_path = wp_normalize_path($real_path); return strpos($real_path, $real_base) === 0 ? $real_path : ''; } private function secure_pdf_target(string $filename) { $base_dir = $this->secure_pdf_base_dir(); if ($base_dir === '' || !$this->ensure_secure_pdf_storage($base_dir)) { return new WP_Error('qa_pdf_store_failed', 'Secure PDF storage could not be created.', ['status' => 500]); } $subdir = wp_date('Y/m'); $target_dir = trailingslashit($base_dir) . $subdir; if (!wp_mkdir_p($target_dir)) { return new WP_Error('qa_pdf_store_failed', 'Secure PDF storage could not be created.', ['status' => 500]); } $name = sanitize_file_name($filename); if ($name === '' || !preg_match('/\.pdf$/i', $name)) { $name = 'qa-report.pdf'; } $name = preg_replace('/\.pdf$/i', '-' . wp_generate_password(16, false, false) . '.pdf', $name); $name = wp_unique_filename($target_dir, $name); return [ 'path' => trailingslashit($target_dir) . $name, 'relative' => 'obyte-qa-tool-secure/' . $subdir . '/' . $name, ]; } private function ensure_secure_pdf_storage(string $base_dir): bool { if (!wp_mkdir_p($base_dir)) { return false; } $index = trailingslashit($base_dir) . 'index.php'; if (!file_exists($index)) { if (file_put_contents($index, "\nRequire all denied\n\n\nDeny from all\n\n") === false) { return false; } } $web_config = trailingslashit($base_dir) . 'web.config'; if (!file_exists($web_config)) { if (file_put_contents($web_config, "\n\n \n \n \n \n \n \n \n \n\n") === false) { return false; } } return true; } private function looks_like_pdf(string $file): bool { $handle = fopen($file, 'rb'); if (!$handle) { return false; } $header = fread($handle, 5); fclose($handle); return $header === '%PDF-'; } private function summary_text(array $run): string { $counts = $this->status_counts((array) ($run['steps'] ?? [])); return sprintf( '%d/%d pass, %d fail, %d skip, %d blocked', $counts['pass'], $counts['total'], $counts['fail'], $counts['skip'], $counts['blocked'] ); } private function status_counts(array $steps): array { $counts = ['total' => 0, 'pass' => 0, 'fail' => 0, 'skip' => 0, 'blocked' => 0, 'na' => 0]; foreach ($steps as $step) { if (!is_array($step)) { continue; } $kind = (string) ($step['kind'] ?? $step['type'] ?? 'step'); if ($kind !== 'step') { continue; } $counts['total']++; $status = $this->normalize_status((string) ($step['status'] ?? '')); if (isset($counts[$status])) { $counts[$status]++; } } return $counts; } private function normalize_status(string $status): string { $status = strtolower(trim($status)); $map = [ 'ok' => 'pass', 'passed' => 'pass', 'success' => 'pass', 'true' => 'pass', 'yes' => 'pass', 'ko' => 'fail', 'failed' => 'fail', 'error' => 'fail', 'x' => 'fail', 'skipped' => 'skip', 'block' => 'blocked', 'n/a' => 'na', 'not applicable' => 'na', ]; $status = $map[$status] ?? $status; return in_array($status, ['pass', 'fail', 'skip', 'blocked', 'na', ''], true) ? $status : ''; } private function safe_filename(string $value): string { $value = sanitize_title($value); return $value !== '' ? $value : 'qa'; } private function base_url(string $url): string { $url = esc_url_raw(trim($url)); return untrailingslashit($url); } private function sanitize_secret_field(string $field, array $input, array $old, string $clear_field): string { if (!empty($input[$clear_field])) { return ''; } if (!array_key_exists($field, $input) || (string) $input[$field] === '') { return (string) ($old[$field] ?? ''); } $value = (string) wp_unslash($input[$field]); $value = preg_replace('/[\x00-\x1F\x7F]/', '', $value); return trim((string) $value); } private function gitlab_headers(array $settings): array { $headers = ['Accept' => 'application/json']; if (!empty($settings['gitlab_token'])) { $headers['PRIVATE-TOKEN'] = (string) $settings['gitlab_token']; } return $headers; } private function remote_json($response, string $error_code) { if (is_wp_error($response)) { return new WP_Error($error_code, $response->get_error_message(), ['status' => 502]); } $code = (int) wp_remote_retrieve_response_code($response); $body = (string) wp_remote_retrieve_body($response); if ($code < 200 || $code >= 300) { return new WP_Error($error_code, 'Remote service returned HTTP ' . $code . '.', ['status' => $code ?: 502]); } $data = json_decode($body, true); if (!is_array($data)) { return new WP_Error($error_code, 'Remote service returned invalid JSON.', ['status' => 502]); } return $data; } private function clean_gitlab_path(string $path): string { $path = trim(wp_unslash($path)); $path = str_replace('\\', '/', $path); $path = ltrim($path, '/'); if ($path === '' || strpos($path, '..') !== false) { return ''; } return $path; } private function is_gitlab_template_file_path(string $path, array $settings): bool { if ($path === '' || !preg_match('/\.ya?ml$/i', $path)) { return false; } $template_path = trim((string) ($settings['gitlab_template_path'] ?? ''), '/'); if ($template_path === '') { return true; } return strpos($path, $template_path . '/') === 0; } private function docbee_token(array $settings) { $payload = [ 'username' => (string) $settings['docbee_username'], 'password' => (string) $settings['docbee_password'], 'lifeTime' => (int) $settings['docbee_lifetime'], 'lifeTimeRefresh' => !empty($settings['docbee_refresh_lifetime']), ]; $response = wp_remote_post($settings['docbee_base_url'] . '/restApi/login', [ 'timeout' => 20, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode($payload), ]); $data = $this->remote_json($response, 'docbee_login_failed'); if (is_wp_error($data)) { return $data; } $token = (string) ($data['access_token'] ?? ''); if ($token === '') { return new WP_Error('docbee_login_failed', 'DocBee did not return an access token.', ['status' => 502]); } return $token; } private function docbee_post_json(string $url, array $payload, string $token) { $response = wp_remote_post($url, [ 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { return new WP_Error('docbee_post_failed', $response->get_error_message(), ['status' => 502]); } $code = (int) wp_remote_retrieve_response_code($response); $body = (string) wp_remote_retrieve_body($response); if ($code < 200 || $code >= 300) { return new WP_Error('docbee_post_failed', 'DocBee returned HTTP ' . $code . '.', ['status' => $code ?: 502]); } return $body; } private function docbee_get_json(string $url, string $token) { $response = wp_remote_get($url, [ 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json', ], ]); return $this->remote_json($response, 'docbee_get_failed'); } private function docbee_put_json(string $url, array $payload, string $token) { $response = wp_remote_request($url, [ 'method' => 'PUT', 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 300) { return new WP_Error('docbee_put_failed', 'DocBee returned HTTP ' . $code . '.', ['status' => $code ?: 502]); } return (string) wp_remote_retrieve_body($response); } private function extract_docbee_status_id(array $ticket): ?int { $candidates = [ $ticket['ticketStatus']['id'] ?? null, $ticket['ticketStatus'] ?? null, $ticket['status']['id'] ?? null, $ticket['status'] ?? null, ]; foreach ($candidates as $candidate) { if (is_numeric($candidate)) { return (int) $candidate; } } return null; } private function docbee_restore_ticket_status(string $ticket_id, int $status_id, array $settings, string $token): bool { $url = $settings['docbee_base_url'] . '/restApi/v1/ticket/' . rawurlencode($ticket_id); $tries = [ ['ticketStatus' => $status_id], ['ticketStatus' => ['id' => $status_id]], ['status' => $status_id], ['status' => ['id' => $status_id]], ]; foreach ($tries as $payload) { $result = $this->docbee_put_json($url, $payload, $token); if (!is_wp_error($result)) { return true; } } return false; } private function extract_ticket_id(string $url): string { if (preg_match('/(?:\/ticket\/show\/|\/tickets\/|\/ticket\/)(\d+)/i', $url, $matches)) { return $matches[1]; } if (preg_match('/^\d+$/', trim($url))) { return trim($url); } return ''; } private function format_docbee_message(array $run): string { $counts = $this->status_counts((array) ($run['steps'] ?? [])); $timestamp = !empty($run['ts']) ? strtotime((string) $run['ts']) : time(); if (!$timestamp) { $timestamp = time(); } $date = wp_date('d.m.Y H:i', $timestamp); $lines = []; $lines[] = 'QA REPORT'; $lines[] = '========='; $lines[] = 'Modul: ' . $this->plain($run['module'] ?? ''); $lines[] = 'Modul-Version: ' . $this->plain($run['module_version'] ?? ''); $lines[] = 'PBX-Version: ' . $this->plain($run['pbx_version'] ?? ''); if (!empty($run['olm_nummer'])) { $lines[] = 'OLM-Nummer: ' . $this->plain($run['olm_nummer']); } $lines[] = 'Tester: ' . $this->plain($run['tester'] ?? ''); if (!empty($run['docbee_url'])) { $lines[] = 'Ticket: ' . $this->plain($run['docbee_url']); } $lines[] = 'Datum: ' . $date; $lines[] = ''; $lines[] = sprintf('Uebersicht: PASS %d | FAIL %d | SKIP %d | BLOCK %d', $counts['pass'], $counts['fail'], $counts['skip'], $counts['blocked']); $lines[] = ''; $lines[] = '------------------------------------------------------------------------'; $lines[] = 'Schritt Status Titel'; $lines[] = '------------------------------------------------------------------------'; foreach ((array) ($run['steps'] ?? []) as $step) { if (!is_array($step)) { continue; } $kind = (string) ($step['kind'] ?? $step['type'] ?? 'step'); if ($kind === 'group') { $lines[] = ''; $lines[] = '## ' . $this->plain($step['title'] ?? ''); $lines[] = ''; continue; } $status = strtoupper((string) ($step['status'] ?? '')); $status = $status === 'BLOCKED' ? 'BLOCK' : $status; $required = !empty($step['required']) ? ' [required]' : ''; $lines[] = sprintf( '%-12s %-7s %s%s', $this->plain($step['id'] ?? ''), $status, $this->plain($step['title'] ?? ''), $required ); if (!empty($step['comment'])) { $lines[] = ' Kommentar: ' . $this->plain($step['comment']); } if (!empty($step['evidence'])) { $lines[] = ' Evidenz: ' . $this->plain($step['evidence']); } } $lines[] = '------------------------------------------------------------------------'; $lines[] = 'Legende: PASS, FAIL, SKIP, BLOCK'; return implode("\n", $lines); } private function docbee_result_url(string $base_url, string $response_body, string $prefix): string { $data = json_decode($response_body, true); if (!is_array($data)) { return ''; } if (!empty($data['link'])) { $link = (string) $data['link']; if (preg_match('/^https?:\/\//i', $link)) { return esc_url_raw($link); } return esc_url_raw(trailingslashit($base_url) . ltrim($link, '/')); } if (!empty($data['id'])) { return esc_url_raw(trailingslashit($base_url) . $prefix . '/' . rawurlencode((string) $data['id'])); } return ''; } private function plain($value): string { $value = wp_strip_all_tags((string) $value); $value = preg_replace('/\s+/', ' ', $value); return trim((string) $value); } } register_activation_hook(__FILE__, ['Obyte_QA_Tool', 'activate']); Obyte_QA_Tool::instance();