PluginOutput::render(): Return correctly cut off output (#1026)

fixes #1012
This commit is contained in:
Johannes Meyer 2024-07-24 16:09:11 +02:00 committed by GitHub
commit 092dca7feb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 223 additions and 143 deletions

View file

@ -4,24 +4,19 @@
namespace Icinga\Module\Icingadb\Util;
use DOMDocument;
use DOMNode;
use DOMText;
use Icinga\Module\Icingadb\Hook\PluginOutputHook;
use Icinga\Module\Icingadb\Model\Host;
use Icinga\Module\Icingadb\Model\Service;
use Icinga\Web\Dom\DomNodeIterator;
use Icinga\Web\Helper\HtmlPurifier;
use InvalidArgumentException;
use ipl\Html\HtmlString;
use ipl\Orm\Model;
use LogicException;
use RecursiveIteratorIterator;
class PluginOutput extends HtmlString
{
/** @var string[] Patterns to be replaced in plain text plugin output */
const TEXT_PATTERNS = [
protected const TEXT_PATTERNS = [
'~\\\t~',
'~\\\n~',
'~(\[|\()OK(\]|\))~',
@ -34,7 +29,7 @@ class PluginOutput extends HtmlString
];
/** @var string[] Replacements for {@see PluginOutput::TEXT_PATTERNS} */
const TEXT_REPLACEMENTS = [
protected const TEXT_REPLACEMENTS = [
"\t",
"\n",
'<span class="state-ball ball-size-m state-ok"></span>',
@ -47,13 +42,13 @@ class PluginOutput extends HtmlString
];
/** @var string[] Patterns to be replaced in html plugin output */
const HTML_PATTERNS = [
protected const HTML_PATTERNS = [
'~\\\t~',
'~\\\n~'
];
/** @var string[] Replacements for {@see PluginOutput::HTML_PATTERNS} */
const HTML_REPLACEMENTS = [
protected const HTML_REPLACEMENTS = [
"\t",
"\n"
];
@ -159,7 +154,7 @@ class PluginOutput extends HtmlString
->setCommandName($object->checkcommand_name);
}
public function render()
public function render(): string
{
if ($this->renderedOutput !== null) {
return $this->renderedOutput;
@ -174,22 +169,24 @@ class PluginOutput extends HtmlString
$output = PluginOutputHook::processOutput($output, $this->commandName, $this->enrichOutput);
}
if (preg_match('~<\w+(?>\s\w+=[^>]*)?>~', $output)) {
// HTML
$output = HtmlPurifier::process(preg_replace(
self::HTML_PATTERNS,
self::HTML_REPLACEMENTS,
$output
));
$this->isHtml = true;
$output = substr($output, 0, $this->characterLimit);
$this->isHtml = (bool) preg_match('~<\w+(?>\s\w+=[^>]*)?>~', $output);
if ($this->isHtml) {
if ($this->enrichOutput) {
$output = preg_replace(self::TEXT_PATTERNS, self::TEXT_REPLACEMENTS, $output);
} else {
$output = preg_replace(self::HTML_PATTERNS, self::HTML_REPLACEMENTS, $output);
}
$output = HtmlPurifier::process($output);
} else {
// Plaintext
$output = preg_replace(
self::TEXT_PATTERNS,
self::TEXT_REPLACEMENTS,
htmlspecialchars($output, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, null, false)
);
$this->isHtml = false;
}
$output = trim($output);
@ -198,84 +195,8 @@ class PluginOutput extends HtmlString
// in oder to help browsers to break words in plugin output
$output = preg_replace('/,(?=[^\s])/', ',&#8203;', $output);
if ($this->enrichOutput && $this->isHtml) {
$output = $this->processHtml($output);
}
if ($this->characterLimit) {
$output = substr($output, 0, $this->characterLimit);
}
$this->renderedOutput = $output;
return $output;
}
/**
* Replace color state information, if any
*
* @param string $html
*
* @todo Do we really need to create a DOM here? Or is a preg_replace like we do it for text also feasible?
* @return string
*/
protected function processHtml(string $html): string
{
$pattern = '/[([](OK|WARNING|CRITICAL|UNKNOWN|UP|DOWN)[)\]]/';
$doc = new DOMDocument();
$doc->loadXML('<div>' . $html . '</div>', LIBXML_NOERROR | LIBXML_NOWARNING);
$dom = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST);
$nodesToRemove = [];
foreach ($dom as $node) {
/** @var DOMNode $node */
if ($node->nodeType !== XML_TEXT_NODE) {
continue;
}
$start = 0;
while (preg_match($pattern, $node->nodeValue, $match, PREG_OFFSET_CAPTURE, $start)) {
$offsetLeft = $match[0][1];
$matchLength = strlen($match[0][0]);
$leftLength = $offsetLeft - $start;
// if there is text before the match
if ($leftLength) {
// create node for leading text
$text = new DOMText(substr($node->nodeValue, $start, $leftLength));
$node->parentNode->insertBefore($text, $node);
}
// create the state ball for the match
$span = $doc->createElement('span');
$span->setAttribute(
'class',
'state-ball ball-size-m state-' . strtolower($match[1][0])
);
$node->parentNode->insertBefore($span, $node);
// start for next match
$start = $offsetLeft + $matchLength;
}
if ($start) {
// is there text left?
if (strlen($node->nodeValue) > $start) {
// create node for trailing text
$text = new DOMText(substr($node->nodeValue, $start));
$node->parentNode->insertBefore($text, $node);
}
// delete the old node later
$nodesToRemove[] = $node;
}
}
foreach ($nodesToRemove as $node) {
/** @var DOMNode $node */
$node->parentNode->removeChild($node);
}
return substr($doc->saveHTML(), 5, -7);
}
}

View file

@ -80,11 +80,6 @@ parameters:
count: 1
path: library/Icingadb/Util/PerfData.php
-
message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:render\\(\\) should return string but returns string\\|false\\|null\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$str of function trim expects string, string\\|null given\\.$#"
count: 1
@ -95,11 +90,6 @@ parameters:
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:\\$renderedOutput \\(string\\) does not accept string\\|false\\|null\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$str of function trim expects string, mixed given\\.$#"
count: 1

View file

@ -80,21 +80,11 @@ parameters:
count: 1
path: library/Icingadb/Util/PerfData.php
-
message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:render\\(\\) should return string but returns string\\|null\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$string of function trim expects string, string\\|null given\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:\\$renderedOutput \\(string\\) does not accept string\\|null\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$string of function trim expects string, mixed given\\.$#"
count: 1

View file

@ -4936,17 +4936,7 @@ parameters:
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Cannot call method insertBefore\\(\\) on DOMNode\\|null\\.$#"
count: 3
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Cannot call method removeChild\\(\\) on DOMNode\\|null\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$html of method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:processHtml\\(\\) expects string, string\\|null given\\.$#"
message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:render\\(\\) should return string but returns string\\|null\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
@ -4956,22 +4946,7 @@ parameters:
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$string of function strlen expects string, string\\|null given\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|false given\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
count: 3
path: library/Icingadb/Util/PluginOutput.php
-
message: "#^Parameter \\#2 \\$subject of function preg_match expects string, string\\|null given\\.$#"
message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:\\$renderedOutput \\(string\\) does not accept string\\|null\\.$#"
count: 1
path: library/Icingadb/Util/PluginOutput.php
@ -6635,6 +6610,11 @@ parameters:
count: 1
path: library/Icingadb/Widget/Detail/UserDetail.php
-
message: "#^Cannot access property \\$display_name on mixed\\.$#"
count: 1
path: library/Icingadb/Widget/Detail/UserDetail.php
-
message: "#^Cannot call method getModel\\(\\) on mixed\\.$#"
count: 1

View file

@ -0,0 +1,199 @@
<?php
/* Icinga DB Web | (c) 2024 Icinga GmbH | GPLv2 */
namespace Tests\Icinga\Module\Icingadb\Util;
use Icinga\Module\Icingadb\Util\PluginOutput;
use PHPUnit\Framework\TestCase;
class PluginOutputTest extends TestCase
{
public function checkOutput(string $expected, string $input, int $characterLimit = 0): void
{
$p = new PluginOutput($input);
if ($characterLimit) {
$p->setCharacterLimit($characterLimit);
}
$this->assertSame($expected, $p->render(), 'PluginOutput::render does not return expected values');
}
public function testRenderPlainText(): void
{
$input = 'This is a plain text';
$this->checkOutput($input, $input);
}
public function testRenderTextWithStates(): void
{
$input = <<<'INPUT'
[OK] Dummy state
\_ [OK] Fake "state"
\_ [WARNING] Fake state again
INPUT;
$expectedOutput = <<<'EXPECTED_OUTPUT'
<span class="state-ball ball-size-m state-ok"></span> Dummy state
\_ <span class="state-ball ball-size-m state-ok"></span> Fake &quot;state&quot;
\_ <span class="state-ball ball-size-m state-warning"></span> Fake state again
EXPECTED_OUTPUT;
$this->checkOutput($expectedOutput, $input);
}
public function testRenderTextWithStatesAndCharacterLimit(): void
{
$input = <<<'INPUT'
[OK] Dummy state
\_ [OK] Fake "state"
\_ [WARNING] Fake state again
INPUT;
$expectedOutput = <<<'EXPECTED_OUTPUT'
<span class="state-ball ball-size-m state-ok"></span> Dummy
EXPECTED_OUTPUT;
$this->checkOutput($expectedOutput, $input, 10);
}
public function testRenderTextWithHtml(): void
{
$input = <<<'INPUT'
Hello <h3>World</h3>, this "is" 'a <strong>test</strong>.
INPUT;
$expectedOutput = <<<'EXPECTED_OUTPUT'
Hello <h3>World</h3>, this "is" 'a <strong>test</strong>.
EXPECTED_OUTPUT;
$this->checkOutput($expectedOutput, $input);
}
public function testRenderTextWithHtmlAndStates(): void
{
$input = <<<'INPUT'
Hello <h3>World</h3>, this "is" a <strong>test</strong>.
[OK] Dummy state
\_ [OK] Fake "state"
\_ [WARNING] Fake state again
text <span> ends </span> here
INPUT;
$expectedOutput = <<<'EXPECTED_OUTPUT'
Hello <h3>World</h3>, this "is" a <strong>test</strong>.
<span class="state-ball ball-size-m state-ok"></span> Dummy state
\_ <span class="state-ball ball-size-m state-ok"></span> Fake "state"
\_ <span class="state-ball ball-size-m state-warning"></span> Fake state again
text <span> ends </span> here
EXPECTED_OUTPUT;
$this->checkOutput($expectedOutput, $input);
}
public function testRenderTextWithHtmlIncludingStatesAndSpecialChars(): void
{
$input = <<<'INPUT'
Hello <h3>World</h3>, this "is" a <strong>test</strong>.
[OK] Dummy state
special chars: !@#$%^&*()_+{}|:"<>?`-=[]\;',./
text <span> ends </span> here
INPUT;
$expectedOutput = <<<'EXPECTED_OUTPUT'
Hello <h3>World</h3>, this "is" a <strong>test</strong>.
<span class="state-ball ball-size-m state-ok"></span> Dummy state
special chars: !@#$%^&amp;*()_+{}|:"&lt;&gt;?`-=[]\;',&#8203;./
text <span> ends </span> here
EXPECTED_OUTPUT;
$this->checkOutput($expectedOutput, $input);
}
public function testOutputWithNewlines(): void
{
$input = 'foo\nbar\n\nraboof';
$expectedOutput = "foo\nbar\n\nraboof";
$this->checkOutput($expectedOutput, $input);
}
public function testOutputWithHtmlEntities(): void
{
$input = 'foo&nbsp;&amp;&nbsp;bar';
$expectedOutput = $input;
$this->checkOutput($expectedOutput, $input);
}
public function testSimpleHtmlOutput(): void
{
$input = <<<'INPUT'
OK - Teststatus <a href="http://localhost/test.php" target="_blank">Info</a>
INPUT;
$expectedOutput = <<<'EXPECTED_OUTPUT'
OK - Teststatus <a href="http://localhost/test.php" target="_blank" rel="noreferrer noopener">Info</a>
EXPECTED_OUTPUT;
$this->checkOutput($expectedOutput, $input);
}
public function testTextStatusTags(): void
{
foreach (['OK', 'WARNING', 'CRITICAL', 'UNKNOWN', 'UP', 'DOWN'] as $s) {
$l = strtolower($s);
$input = sprintf('[%s] Test', $s);
$expectedOutput = sprintf('<span class="state-ball ball-size-m state-%s"></span> Test', $l);
$this->checkOutput($expectedOutput, $input);
$input = sprintf('(%s) Test', $s);
$this->checkOutput($expectedOutput, $input);
}
}
public function testHtmlStatusTags(): void
{
$dummyHtml = '<a href="#"></a>';
foreach (['OK', 'WARNING', 'CRITICAL', 'UNKNOWN', 'UP', 'DOWN'] as $s) {
$l = strtolower($s);
$input = sprintf('%s [%s] Test', $dummyHtml, $s);
$expectedOutput = sprintf('%s <span class="state-ball ball-size-m state-%s"></span> Test', $dummyHtml, $l);
$this->checkOutput($expectedOutput, $input);
$input = sprintf('%s (%s) Test', $dummyHtml, $s);
$this->checkOutput($expectedOutput, $input);
}
}
public function testNewlineProcessingInHtmlOutput(): void
{
$input = 'This is plugin output\n\n<ul>\n <li>with a HTML list</li>\n</ul>\n\n'
. 'and more text that\nis split onto multiple\n\nlines';
$expectedOutput = <<<'EXPECTED_OUTPUT'
This is plugin output
<ul>
<li>with a HTML list</li>
</ul>
and more text that
is split onto multiple
lines
EXPECTED_OUTPUT;
$this->checkOutput($expectedOutput, $input);
}
}