diff options
Diffstat (limited to 'library/Icingadb/Util/PluginOutput.php')
-rw-r--r-- | library/Icingadb/Util/PluginOutput.php | 260 |
1 files changed, 260 insertions, 0 deletions
diff --git a/library/Icingadb/Util/PluginOutput.php b/library/Icingadb/Util/PluginOutput.php new file mode 100644 index 0000000..71d08b1 --- /dev/null +++ b/library/Icingadb/Util/PluginOutput.php @@ -0,0 +1,260 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +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 = [ + '~\\\t~', + '~\\\n~', + '~(\[|\()OK(\]|\))~', + '~(\[|\()WARNING(\]|\))~', + '~(\[|\()CRITICAL(\]|\))~', + '~(\[|\()UNKNOWN(\]|\))~', + '~(\[|\()UP(\]|\))~', + '~(\[|\()DOWN(\]|\))~', + '~\@{6,}~' + ]; + + /** @var string[] Replacements for {@see PluginOutput::TEXT_PATTERNS} */ + const TEXT_REPLACEMENTS = [ + "\t", + "\n", + '<span class="state-ball ball-size-m state-ok"></span>', + '<span class="state-ball ball-size-m state-warning"></span>', + '<span class="state-ball ball-size-m state-critical"></span>', + '<span class="state-ball ball-size-m state-unknown"></span>', + '<span class="state-ball ball-size-m state-up"></span>', + '<span class="state-ball ball-size-m state-down"></span>', + '@@@@@@' + ]; + + /** @var string[] Patterns to be replaced in html plugin output */ + const HTML_PATTERNS = [ + '~\\\t~', + '~\\\n~' + ]; + + /** @var string[] Replacements for {@see PluginOutput::HTML_PATTERNS} */ + const HTML_REPLACEMENTS = [ + "\t", + "\n" + ]; + + /** @var string Already rendered output */ + protected $renderedOutput; + + /** @var bool Whether the output contains HTML */ + protected $isHtml; + + /** @var bool Whether output will be enriched */ + protected $enrichOutput = true; + + /** @var string The name of the command that produced the output */ + protected $commandName; + + /** + * Get whether the output contains HTML + * + * Requires the output being already rendered. + * + * @return bool + * + * @throws LogicException In case the output hasn't been rendered yet + */ + public function isHtml(): bool + { + if ($this->isHtml === null) { + if (empty($this->getContent())) { + // "Nothing" can't be HTML + return false; + } + + throw new LogicException('Output not rendered yet'); + } + + return $this->isHtml; + } + + /** + * Set whether the output should be enriched + * + * @param bool $state + * + * @return $this + */ + public function setEnrichOutput(bool $state = true): self + { + $this->enrichOutput = $state; + + return $this; + } + + /** + * Set name of the command that produced the output + * + * @param string $name + * + * @return $this + */ + public function setCommandName(string $name): self + { + $this->commandName = $name; + + return $this; + } + + /** + * Render plugin output of the given object + * + * @param Host|Service $object + * + * @return static + * + * @throws InvalidArgumentException If $object is neither a host nor a service + */ + public static function fromObject(Model $object): self + { + if (! $object instanceof Host && ! $object instanceof Service) { + throw new InvalidArgumentException( + sprintf('Object is not a host or service, got %s instead', get_class($object)) + ); + } + + return (new static($object->state->output . "\n" . $object->state->long_output)) + ->setCommandName($object->checkcommand_name); + } + + public function render() + { + if ($this->renderedOutput !== null) { + return $this->renderedOutput; + } + + $output = parent::render(); + if (empty($output)) { + return ''; + } + + if ($this->commandName !== null) { + $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; + } 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); + + // Add zero-width space after commas which are not followed by a whitespace character + // in oder to help browsers to break words in plugin output + $output = preg_replace('/,(?=[^\s])/', ',​', $output); + + if ($this->enrichOutput && $this->isHtml) { + $output = $this->processHtml($output); + } + + $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); + } +} |