This commit is contained in:
Sven Steinert
2026-05-04 19:20:22 +02:00
parent fce31ebcd7
commit ec97e1097c
1254 changed files with 421 additions and 174285 deletions

View File

@@ -7,30 +7,30 @@ WordPress plugin version of the legacy `qa-tool` app.
- Frontend shortcode: `[obyte_qa_tool]`
- WordPress backend settings for GitLab and DocBee configuration
- GitLab template loading through a WordPress REST proxy
- GitLab template writeback through WordPress REST, using the backend token
- GitLab template writeback through WordPress REST, using only the token saved in the backend
- Local YAML/JSON template loading
- Full YAML parsing through `js-yaml` with a small built-in fallback parser
- Editable QA steps and groups with drag and drop
- Required-step validation
- Run save/load as JSON
- Markdown, CSV, printable PDF, and YAML template export
- Combined export: DocBee post, WordPress database storage, and Media Library PDF upload
- Combined export: DocBee post, WordPress database storage, and protected PDF storage
- DocBee ticket posting through a server-side REST endpoint with optional ticket-status restoration
## Setup
1. Copy or keep the `obyte-qa-tool` folder in `wp-content/plugins/`.
2. Activate **o-Byte QA Tool** in WordPress.
3. Open **Settings > o-Byte QA Tool**.
3. Open **QA Tool > Settings**.
4. Enter GitLab and DocBee settings. Secrets are stored as WordPress options and are not exposed to frontend JavaScript.
5. Add `[obyte_qa_tool]` to the page where the QA runner should appear.
6. Saved exports can be reviewed under **Tools > o-Byte QA Reports**.
6. Saved exports can be reviewed under **QA Tool > Reports**.
## Notes
- The legacy standalone OIDC login is replaced by WordPress login and capability checks.
- The REST endpoints require the configured WordPress capability.
- GitLab and DocBee credentials from the old PHP files are intentionally not hardcoded into the plugin.
- Access control is expected to be handled by the site/OAuth tag layer before the shortcode is shown.
- The REST endpoints require a logged-in WordPress session, but no plugin-owned capability setting.
- GitLab and DocBee credentials are never hardcoded; secrets must be entered and stored through the backend settings.
- Reports are stored in WordPress-owned custom tables: `wp_obyte_qa_reports` and `wp_obyte_qa_steps` using the active site prefix.
- Exported PDFs are stored as normal Media Library attachments when enabled.
- Exported PDFs are stored in protected plugin storage when enabled. Backend report links are short-lived one-time links.
- Client-side PDF generation uses jsPDF/AutoTable CDNs, matching the standalone tool's browser-based export model.

View File

@@ -14,11 +14,22 @@
font-display: swap;
}
.obyte-qa-tool-shell,
.obyte-qa-tool,
.obyte-qa-tool * {
box-sizing: border-box;
}
.obyte-qa-tool-shell {
width: 100vw;
max-width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
padding: 0 clamp(16px, 3vw, 34px);
overflow-x: clip;
background: #f5fbfe;
}
.obyte-qa-tool {
--oqt-blue: #00a7e6;
--oqt-blue-soft: #33b9eb;
@@ -36,8 +47,9 @@
--oqt-danger: #c43b3b;
--oqt-muted: #67717a;
--oqt-focus: 0 0 0 3px rgba(0, 167, 230, 0.28);
width: 100%;
margin: 0;
width: min(1240px, 100%);
max-width: 100%;
margin: 0 auto;
overflow: visible;
border: 1px solid var(--oqt-line);
border-radius: 8px;

View File

@@ -25,6 +25,8 @@
this.root = root;
this.template = null;
this.dragIndex = -1;
this.gitlabTemplatePath = "";
this.gitlabTemplateModule = "";
this.storageKey = "obyteQaToolState:" + window.location.pathname;
this.els = this.collectElements();
this.init();
@@ -128,11 +130,15 @@
self.loadGitlabTemplate(event.target.value);
});
bind(this.els.addStep, "click", function () {
bind(this.els.addStep, "click", function (event) {
event.preventDefault();
event.stopImmediatePropagation();
self.addStep();
});
bind(this.els.addGroup, "click", function () {
bind(this.els.addGroup, "click", function (event) {
event.preventDefault();
event.stopImmediatePropagation();
self.addGroup();
});
@@ -274,13 +280,15 @@
QaTool.prototype.loadGitlabTemplate = async function (path) {
if (!path) {
this.gitlabTemplatePath = "";
this.gitlabTemplateModule = "";
return;
}
try {
this.setTag(this.els.gitlabTplStatus, "GitLab: lade Datei", "");
var data = await this.api("template?path=" + encodeURIComponent(path));
this.loadTemplateText(data.content || "", path.split("/").pop());
this.loadTemplateText(data.content || "", path.split("/").pop(), data.path || path);
this.setTag(this.els.gitlabTplStatus, "GitLab: geladen", "ok");
} catch (error) {
this.setTag(this.els.gitlabTplStatus, "GitLab: Fehler", "bad");
@@ -305,10 +313,12 @@
}
};
QaTool.prototype.loadTemplateText = function (text, fallbackName) {
QaTool.prototype.loadTemplateText = function (text, fallbackName, sourcePath) {
var parsed = parseTemplateText(text);
var normalized = normalizeTemplate(parsed, fallbackName || "Template");
this.template = normalized;
this.gitlabTemplatePath = sourcePath || "";
this.gitlabTemplateModule = sourcePath ? (normalized.module || "") : "";
if (this.els.tplName) {
this.els.tplName.textContent = normalized.name || fallbackName || "Template";
@@ -440,7 +450,9 @@
return;
}
var rows = Array.prototype.slice.call(this.els.stepsTableBody.querySelectorAll("tr"));
var rows = Array.prototype.slice.call(this.els.stepsTableBody.querySelectorAll("tr")).filter(function (row) {
return !row.classList.contains("oqt-empty-row");
});
this.template.steps = rows.map(function (row, index) {
var kind = row.dataset.kind || "step";
if (kind === "group") {
@@ -474,6 +486,8 @@
pbx_version: config.defaultPbxVersion || "",
steps: []
};
this.gitlabTemplatePath = "";
this.gitlabTemplateModule = "";
if (this.els.tplName) {
this.els.tplName.textContent = this.template.name;
}
@@ -481,8 +495,11 @@
};
QaTool.prototype.addStep = function () {
var hadTemplate = !!this.template;
this.ensureTemplate();
this.captureEditsIntoTemplate();
if (hadTemplate) {
this.captureEditsIntoTemplate();
}
this.template.steps.push({
kind: "step",
id: "",
@@ -499,8 +516,11 @@
};
QaTool.prototype.addGroup = function () {
var hadTemplate = !!this.template;
this.ensureTemplate();
this.captureEditsIntoTemplate();
if (hadTemplate) {
this.captureEditsIntoTemplate();
}
this.template.steps.push({
kind: "group",
title: "Neue Gruppe",
@@ -882,6 +902,8 @@
pbx_version: run.pbx_version || "",
steps: run.steps
}, file.name);
this.gitlabTemplatePath = "";
this.gitlabTemplateModule = "";
if (this.els.tplName) {
this.els.tplName.textContent = run.name || file.name;
@@ -1251,8 +1273,8 @@
if (data.report_id) {
parts.push("Report-ID: " + data.report_id);
}
if (data.pdf_url) {
parts.push('<a href="' + escAttr(data.pdf_url) + '" target="_blank" rel="noopener">PDF in Mediathek öffnen</a>');
if (data.pdf_stored) {
parts.push("PDF geschuetzt gespeichert");
}
if (data.docbee && data.docbee.ok) {
var docbeeLink = data.docbee.url ? ' <a href="' + escAttr(data.docbee.url) + '" target="_blank" rel="noopener">DocBee öffnen</a>' : "";
@@ -1295,11 +1317,16 @@
button.classList.add("is-busy");
}
this.showMessage("GitLab: Template wird geschrieben...", "");
var sourcePath = this.gitlabTemplatePath && sameModuleName(template.module, this.gitlabTemplateModule)
? this.gitlabTemplatePath
: "";
var data = await this.api("gitlab/template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template: template, yaml: yaml })
body: JSON.stringify({ template: template, yaml: yaml, source_path: sourcePath })
});
this.gitlabTemplatePath = data.path || sourcePath || "";
this.gitlabTemplateModule = template.module || "";
this.showMessage("GitLab: Template gespeichert: " + (data.path || ""), "ok");
} catch (error) {
this.showMessage("GitLab-Push fehlgeschlagen: " + error.message, "bad");
@@ -1800,6 +1827,10 @@
.replace(/[^\w.-]/g, "") || "qa";
}
function sameModuleName(left, right) {
return String(left || "").trim() === String(right || "").trim();
}
function mdCell(valueToEscape) {
return String(valueToEscape || "").replace(/\|/g, "\\|").replace(/\n/g, " ");
}

View File

@@ -2,7 +2,7 @@
/**
* Plugin Name: o-Byte QA Tool
* Description: WordPress version of the o-Byte manual QA runner with GitLab templates, exports, and DocBee posting.
* Version: 1.0.1
* Version: 1.0.8
* Author: o-byte.com
* Text Domain: obyte-qa-tool
*/
@@ -13,7 +13,7 @@ if (!defined('ABSPATH')) {
final class Obyte_QA_Tool
{
private const VERSION = '1.0.1';
private const VERSION = '1.0.8';
private const OPTION_NAME = 'obyte_qa_tool_settings';
private const REST_NAMESPACE = 'obyte-qa-tool/v1';
@@ -44,13 +44,13 @@ final class Obyte_QA_Tool
add_action('admin_enqueue_scripts', [$this, 'register_assets']);
add_action('admin_menu', [$this, 'register_admin_page']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_post_obyte_qa_pdf', [$this, 'download_secure_pdf']);
add_action('rest_api_init', [$this, 'register_rest_routes']);
}
private static function defaults(): array
{
return [
'required_capability' => 'read',
'default_pbx_version' => '',
'gitlab_enabled' => 1,
'gitlab_base_url' => 'https://git.steinert.cc',
@@ -118,6 +118,7 @@ final class Obyte_QA_Tool
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)),
@@ -213,9 +214,6 @@ final class Obyte_QA_Tool
public function render_shortcode(array $atts = []): string
{
$settings = $this->settings();
$capability = $this->required_capability($settings);
if (!is_user_logged_in()) {
$login_url = wp_login_url(get_permalink());
return '<div class="obyte-qa-tool-notice">' .
@@ -223,17 +221,12 @@ final class Obyte_QA_Tool
' <a href="' . esc_url($login_url) . '">' . esc_html__('Zum Login', 'obyte-qa-tool') . '</a></div>';
}
if (!current_user_can($capability)) {
return '<div class="obyte-qa-tool-notice">' .
esc_html__('Dein WordPress Benutzer hat keine Berechtigung für das QA Tool.', 'obyte-qa-tool') .
'</div>';
}
$root_id = wp_unique_id('obyte-qa-tool-');
$this->enqueue_frontend_assets($root_id);
ob_start();
?>
<div class="obyte-qa-tool-shell">
<div id="<?php echo esc_attr($root_id); ?>" class="obyte-qa-tool" data-obyte-qa-tool>
<header class="oqt-header">
<div class="oqt-title-block">
@@ -378,6 +371,7 @@ final class Obyte_QA_Tool
<div class="oqt-message" data-field="message" aria-live="polite"></div>
</section>
</div>
</div>
<?php
return (string) ob_get_clean();
@@ -385,17 +379,29 @@ final class Obyte_QA_Tool
public function register_admin_page(): void
{
add_options_page(
'o-Byte QA Tool',
add_menu_page(
'o-Byte QA Tool',
'QA Tool',
'manage_options',
'obyte-qa-tool',
[$this, 'render_admin_page'],
'dashicons-clipboard',
58
);
add_submenu_page(
'obyte-qa-tool',
'o-Byte QA Tool Settings',
'Settings',
'manage_options',
'obyte-qa-tool',
[$this, 'render_admin_page']
);
add_management_page(
'o-Byte QA Reports',
add_submenu_page(
'obyte-qa-tool',
'o-Byte QA Reports',
'Reports',
'manage_options',
'obyte-qa-reports',
[$this, 'render_reports_page']
@@ -421,7 +427,6 @@ final class Obyte_QA_Tool
$old = $this->settings();
$out = self::defaults();
$out['required_capability'] = sanitize_text_field((string) ($input['required_capability'] ?? $old['required_capability']));
$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'] ?? ''));
@@ -467,13 +472,6 @@ final class Obyte_QA_Tool
<h2>Allgemein</h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="oqt-required-capability">Berechtigung</label></th>
<td>
<input id="oqt-required-capability" class="regular-text" type="text" name="<?php echo esc_attr($option); ?>[required_capability]" value="<?php echo esc_attr($settings['required_capability']); ?>">
<p class="description">WordPress Capability für Benutzer, die das Tool nutzen dürfen. Standard: <code>read</code>.</p>
</td>
</tr>
<tr>
<th scope="row"><label for="oqt-default-pbx-version">Standard PBX-Version</label></th>
<td>
@@ -535,7 +533,10 @@ final class Obyte_QA_Tool
</tr>
<tr>
<th scope="row">PDFs</th>
<td><label><input type="checkbox" name="<?php echo esc_attr($option); ?>[media_pdf_enabled]" value="1" <?php checked($settings['media_pdf_enabled']); ?>> Export-PDFs in der WordPress Mediathek speichern</label></td>
<td>
<label><input type="checkbox" name="<?php echo esc_attr($option); ?>[media_pdf_enabled]" value="1" <?php checked($settings['media_pdf_enabled']); ?>> Export-PDFs geschützt speichern</label>
<p class="description">PDFs werden nicht als öffentliche Media-Library-Dateien abgelegt. Der Zugriff erfolgt über einmalige Backend-Links.</p>
</td>
</tr>
</table>
@@ -597,6 +598,7 @@ final class Obyte_QA_Tool
global $wpdb;
self::create_tables();
$reports_table = self::reports_table();
$this->migrate_legacy_pdf_attachments(500);
$rows = $wpdb->get_results("SELECT * FROM {$reports_table} ORDER BY created_at DESC LIMIT 100", ARRAY_A);
?>
<div class="wrap obyte-qa-reports">
@@ -621,6 +623,10 @@ final class Obyte_QA_Tool
<tr><td colspan="9">Noch keine QA Reports gespeichert.</td></tr>
<?php else : ?>
<?php foreach ($rows as $row) : ?>
<?php
$row = $this->ensure_secure_report_pdf($row);
$pdf_download_url = $this->create_pdf_download_url($row);
?>
<tr>
<td><?php echo esc_html((string) $row['id']); ?></td>
<td><?php echo esc_html((string) $row['created_at']); ?></td>
@@ -630,8 +636,8 @@ final class Obyte_QA_Tool
<td><?php echo esc_html((string) $row['tester']); ?></td>
<td><?php echo esc_html((string) $row['summary']); ?></td>
<td>
<?php if (!empty($row['pdf_url'])) : ?>
<a href="<?php echo esc_url((string) $row['pdf_url']); ?>" target="_blank" rel="noopener">PDF</a>
<?php if ($pdf_download_url !== '') : ?>
<a href="<?php echo esc_url($pdf_download_url); ?>" target="_blank" rel="noopener">PDF</a>
<?php else : ?>
-
<?php endif; ?>
@@ -654,6 +660,159 @@ final class Obyte_QA_Tool
<?php
}
public function download_secure_pdf(): void
{
if (!is_user_logged_in() || !current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to download this PDF.', 'obyte-qa-tool'), '', ['response' => 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', [
@@ -701,8 +860,7 @@ final class Obyte_QA_Tool
public function rest_can_use(): bool
{
$settings = $this->settings();
return is_user_logged_in() && current_user_can($this->required_capability($settings));
return is_user_logged_in();
}
public function rest_list_templates(WP_REST_Request $request)
@@ -832,22 +990,20 @@ final class Obyte_QA_Tool
}
}
$attachment_id = 0;
$pdf_url = '';
$pdf_file = '';
$files = $request->get_file_params();
if (!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_pdf_attachment($files['pdf'], $run);
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;
}
$attachment_id = (int) $uploaded['attachment_id'];
$pdf_url = (string) $uploaded['url'];
$pdf_file = (string) $uploaded['file'];
}
$report_id = 0;
$summary = $this->summary_text($run);
if (!empty($settings['storage_enabled'])) {
$stored = $this->store_report($run, $summary, $attachment_id, $pdf_url, $docbee);
$stored = $this->store_report($run, $summary, $pdf_file, $docbee);
if (is_wp_error($stored)) {
return $stored;
}
@@ -858,8 +1014,7 @@ final class Obyte_QA_Tool
'ok' => true,
'report_id' => $report_id,
'summary' => $summary,
'pdf_attachment_id' => $attachment_id,
'pdf_url' => $pdf_url,
'pdf_stored' => $pdf_file !== '',
'docbee' => $docbee,
]);
}
@@ -877,16 +1032,25 @@ final class Obyte_QA_Tool
$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]);
}
$filename = $this->safe_filename((string) ($template['module'] ?? $template['name'] ?? 'qa-template')) . '.yaml';
$template_path = trim((string) $settings['gitlab_template_path'], '/');
$path = ($template_path !== '' ? $template_path . '/' : '') . $filename;
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);
@@ -929,12 +1093,18 @@ final class Obyte_QA_Tool
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' => is_array($rows) ? $rows : [],
'reports' => $rows,
]);
}
@@ -951,12 +1121,6 @@ final class Obyte_QA_Tool
return '';
}
private function required_capability(array $settings): string
{
$capability = trim((string) ($settings['required_capability'] ?? 'read'));
return $capability !== '' ? $capability : 'read';
}
private function docbee_configured(array $settings): bool
{
return !empty($settings['docbee_base_url'])
@@ -1050,7 +1214,7 @@ final class Obyte_QA_Tool
];
}
private function store_report(array $run, string $summary, int $attachment_id, string $pdf_url, ?array $docbee)
private function store_report(array $run, string $summary, string $pdf_file, ?array $docbee)
{
global $wpdb;
@@ -1073,11 +1237,12 @@ final class Obyte_QA_Tool
'docbee_url' => esc_url_raw((string) ($run['docbee_url'] ?? '')),
'docbee_result_url' => esc_url_raw($docbee_result_url),
'summary' => $summary,
'pdf_attachment_id' => $attachment_id,
'pdf_url' => esc_url_raw($pdf_url),
'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', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
);
if (!$inserted) {
@@ -1124,15 +1289,15 @@ final class Obyte_QA_Tool
return $report_id;
}
private function store_pdf_attachment(array $file, array $run)
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]);
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
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',
@@ -1142,53 +1307,143 @@ final class Obyte_QA_Tool
wp_date('Ymd_His')
);
$file_array = [
'name' => $filename,
'type' => 'application/pdf',
'tmp_name' => $file['tmp_name'],
'error' => (int) ($file['error'] ?? 0),
'size' => (int) ($file['size'] ?? 0),
];
return $this->store_secure_pdf_file((string) $file['tmp_name'], $filename, true);
}
$upload = wp_handle_sideload($file_array, [
'test_form' => false,
'mimes' => ['pdf' => 'application/pdf'],
]);
if (!empty($upload['error'])) {
return new WP_Error('qa_pdf_upload_failed', (string) $upload['error'], ['status' => 500]);
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]);
}
$title = 'QA Report';
if (!empty($run['module'])) {
$title .= ' - ' . $this->plain($run['module']);
$target = $this->secure_pdf_target($filename);
if (is_wp_error($target)) {
return $target;
}
$attachment_id = wp_insert_attachment(
[
'post_mime_type' => 'application/pdf',
'post_title' => $title,
'post_content' => '',
'post_status' => 'inherit',
],
$upload['file']
);
if (is_wp_error($attachment_id) || !$attachment_id) {
return new WP_Error('qa_pdf_attachment_failed', 'PDF could not be added to the Media Library.', ['status' => 500]);
if ($uploaded) {
$stored = move_uploaded_file($source_file, $target['path']);
} else {
$stored = copy($source_file, $target['path']);
}
$metadata = wp_generate_attachment_metadata($attachment_id, $upload['file']);
if (is_array($metadata)) {
wp_update_attachment_metadata($attachment_id, $metadata);
if (!$stored) {
return new WP_Error('qa_pdf_store_failed', 'PDF could not be stored securely.', ['status' => 500]);
}
@chmod($target['path'], 0640);
return [
'attachment_id' => (int) $attachment_id,
'url' => wp_get_attachment_url($attachment_id) ?: '',
'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, "<?php\n// Silence is golden.\n") === false) {
return false;
}
}
$htaccess = trailingslashit($base_dir) . '.htaccess';
if (!file_exists($htaccess)) {
if (file_put_contents($htaccess, "Options -Indexes\n<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n") === false) {
return false;
}
}
$web_config = trailingslashit($base_dir) . 'web.config';
if (!file_exists($web_config)) {
if (file_put_contents($web_config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n <system.webServer>\n <security>\n <authorization>\n <remove users=\"*\" roles=\"\" verbs=\"\" />\n <add accessType=\"Deny\" users=\"*\" />\n </authorization>\n </security>\n </system.webServer>\n</configuration>\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'] ?? []));
@@ -1318,6 +1573,20 @@ final class Obyte_QA_Tool
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 = [