diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php
index 6df82d7..dec5317 100644
--- a/application/controllers/ConfigController.php
+++ b/application/controllers/ConfigController.php
@@ -6,10 +6,21 @@
namespace Icinga\Module\Pdfexport\Controllers;
use Icinga\Application\Config;
-use Icinga\Module\Pdfexport\Forms\ChromeBinaryForm;
-use Icinga\Web\Controller;
+use Icinga\Application\Logger;
+use Icinga\Module\Pdfexport\Forms\BackendConfigForm;
+use Icinga\Web\Form\ConfigForm;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\HtmlString;
+use ipl\Html\Table;
+use ipl\Web\Compat\CompatController;
+use Icinga\Web\Widget\Tabs;
+use ipl\Web\Widget\ButtonLink;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
-class ConfigController extends Controller
+class ConfigController extends CompatController
{
public function init()
{
@@ -18,14 +29,100 @@ public function init()
parent::init();
}
- public function chromeAction()
+ public function backendsAction(): void
{
- $form = (new ChromeBinaryForm())
- ->setIniConfig(Config::module('pdfexport'));
+ $button = new ButtonLink(
+ $this->translate('Create a New Backend'),
+ 'pdfexport/config/createbackend',
+ 'plus',
+ ['title' => $this->translate('Create a New Backend')],
+ );
+ $button->setBaseTarget('_next');
+ $this->addContent($button);
- $form->handleRequest();
+ $table = new Table();
+ $table->setAttributes(Attributes::create([
+ 'class' => 'table-row-selectable common-table',
+ 'data-base-target' => '_next',
+ ]));
+ $table->add(Table::tr([
+ Table::th($this->translate('Backend')),
+ Table::th($this->translate('Priority')),
+ ]));
- $this->view->tabs = $this->Module()->getConfigTabs()->activate('chrome');
- $this->view->form = $form;
+ $config = Config::module('pdfexport');
+
+ $sections = [];
+ foreach ($config as $name => $data) {
+ $sections[] = [$name, $data, (int) $data->get('priority')];
+ }
+
+ usort($sections, function ($a, $b) {
+ return $a[2] <=> $b[2];
+ });
+
+ foreach ($sections as [$name, $data]) {
+ $table->add(Table::tr([
+ Table::td([
+ new Icon('print'),
+ new Link($name, 'pdfexport/config/backend?backend=' . $name),
+ ]),
+ Table::td($data->get('priority')),
+ ], [
+ 'class' => 'clickable',
+ ]));
+ }
+
+ $this->mergeTabs($this->Module()->getConfigTabs()->activate('backends'));
+ $this->addContent($table);
+ }
+
+ public function backendAction(): void
+ {
+ $name = $this->params->shiftRequired('backend');
+ $this->addTitleTab($this->translate(sprintf('Edit %s', $name)));
+
+ $form = new BackendConfigForm();
+ $form->setConfig(Config::module('pdfexport'));
+ $form->setSection($name);
+
+ $form->on(Form::ON_SUBMIT, function () use ($form) {
+ Notification::success($this->translate('Updated print backend'));
+ $this->redirectNow('__CLOSE__');
+ });
+
+ $form->on(ConfigForm::ON_DELETE, function () use ($form) {
+ Notification::success($this->translate('Print backend deleted'));
+ $this->redirectNow('__CLOSE__');
+ });
+
+ $form->handleRequest($this->getServerRequest());
+
+ $this->addContent(HtmlString::create($form->render()));
+ }
+
+ public function createbackendAction(): void
+ {
+ $this->addTitleTab($this->translate(sprintf('Create Print Backend')));
+
+ $form = new BackendConfigForm();
+ $form->setConfig(Config::module('pdfexport'));
+ $form->setIsCreateForm(true);
+
+ $form->on(Form::ON_SUBMIT, function () {
+ Notification::success($this->translate('Created new print backend'));
+ $this->redirectNow('__CLOSE__');
+ });
+
+ $form->handleRequest($this->getServerRequest());
+
+ $this->addContent($form);
+ }
+
+ protected function mergeTabs(Tabs $tabs): void
+ {
+ foreach ($tabs->getTabs() as $tab) {
+ $this->tabs->add($tab->getName(), $tab);
+ }
}
}
diff --git a/application/forms/BackendConfigForm.php b/application/forms/BackendConfigForm.php
new file mode 100644
index 0000000..8685c79
--- /dev/null
+++ b/application/forms/BackendConfigForm.php
@@ -0,0 +1,173 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\Forms;
+
+use Exception;
+use Icinga\Module\Pdfexport\Backend\Chromedriver;
+use Icinga\Module\Pdfexport\Backend\Geckodriver;
+use Icinga\Module\Pdfexport\Backend\HeadlessChromeBackend;
+use Icinga\Web\Form\ConfigForm;
+use ipl\Validator\CallbackValidator;
+
+class BackendConfigForm extends ConfigForm
+{
+ public function assemble(): void
+ {
+ $this->addSectionNameElement();
+
+ $this->addElement('number', 'priority', [
+ 'label' => $this->translate('Priority'),
+ 'required' => true,
+ 'placeholder' => 100,
+ 'min' => 0,
+ 'description' => $this->translate('The priority of the backend. A lower priority will be used first.'),
+ ]);
+
+ $this->addElement('select', 'type', [
+ 'label' => $this->translate('Type'),
+ 'multiOptions' => [
+ '' => sprintf(' - %s - ', t('Please choose')),
+ 'chrome_webdriver' => t('Chrome WebDriver'),
+ 'firefox_webdriver' => t('Firefox WebDriver'),
+ 'remote_chrome' => t('Headless Chrome (Remote)'),
+ 'local_chrome' => t('Headless Chrome (Local)'),
+ ],
+ 'required' => true,
+ 'class' => 'autosubmit',
+ ]);
+
+ $type = $this->getPopulatedValue('type') ?? $this->getConfigValue('type');
+
+ switch ($type) {
+ case 'remote_chrome':
+ $this->addElement('text', 'host', [
+ 'label' => $this->translate('Host'),
+ 'description' => $this->translate('Host address of the server with the running web browser.'),
+ 'required' => true,
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator) {
+ $port = $this->getValue('port') ?: 9222;
+
+ try {
+ $chrome = HeadlessChromeBackend::createRemote($value, $port);
+ $version = $chrome->getVersion();
+ } catch (Exception $e) {
+ $validator->addMessage($e->getMessage());
+ return false;
+ }
+
+ if ($version < HeadlessChromeBackend::MIN_SUPPORTED_CHROME_VERSION) {
+ $validator->addMessage(t(
+ 'Chrome/Chromium supporting headless mode required'
+ . ' which is provided since version %s. Version detected: %s',
+ ));
+ return false;
+ }
+
+ return true;
+ }),
+ ],
+ ]);
+
+ $this->addElement('number', 'port', [
+ 'label' => $this->translate('Port'),
+ 'description' => $this->translate('Port of the chrome developer tools. (Default: 9222)'),
+ 'placeholder' => 9222,
+ 'min' => 1,
+ 'max' => 65535,
+ ]);
+
+ break;
+
+ case 'local_chrome':
+ $this->addElement('text', 'binary', [
+ 'label' => $this->translate('Binary'),
+ 'placeholder' => '/usr/bin/google-chrome',
+ 'description' => $this->translate('Path to the binary of the web browser.'),
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator) {
+ if (empty($value)) {
+ return true;
+ }
+
+ try {
+ $chrome = (HeadlessChromeBackend::createLocal($value));
+ $version = $chrome->getVersion();
+ } catch (Exception $e) {
+ $validator->addMessage($e->getMessage());
+ return false;
+ }
+
+ if ($version < HeadlessChromeBackend::MIN_SUPPORTED_CHROME_VERSION) {
+ $validator->addMessage(t(
+ 'Chrome/Chromium supporting headless mode required'
+ . ' which is provided since version %s. Version detected: %s',
+ ));
+ }
+
+ return true;
+ }),
+ ],
+ ]);
+
+ $this->addElement('checkbox', 'force_temp_storage', [
+ 'label' => $this->translate('Use temp storage'),
+ 'description' => $this->translate(
+ 'Use temp storage to transfer the html to the local chrome instance.'
+ ),
+ 'checkedValue' => '1',
+ 'uncheckedValue' => '0',
+ ]);
+
+ break;
+
+ case 'firefox_webdriver':
+ case 'chrome_webdriver':
+ $this->addElement('text', 'host', [
+ 'label' => $this->translate('Host'),
+ 'description' => $this->translate('Host address of the webdriver server'),
+ 'required' => true,
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator) use ($type) {
+ $port = $this->getValue('port') ?: 4444;
+
+ try {
+ $url = "$value:$port";
+ $backend = match ($type) {
+ 'chrome_webdriver' => new Chromedriver($url),
+ 'firefox_webdriver' => new Geckodriver($url),
+ default => throw new Exception("Invalid webdriver type $type"),
+ };
+
+ if (! $backend->isSupported()) {
+ $validator->addMessage(
+ t('The webdriver server reports that it is unable to generate PDFs'),
+ );
+ return false;
+ }
+ } catch (Exception $e) {
+ $validator->addMessage($e->getMessage());
+ return false;
+ }
+ return true;
+ }),
+ ],
+ ]);
+
+ $this->addElement('number', 'port', [
+ 'label' => $this->translate('Port'),
+ 'description' => $this->translate('Port of the webdriver instance. (Default: 4444)'),
+ 'placeholder' => 4444,
+ 'min' => 1,
+ 'max' => 65535,
+ ]);
+
+ break;
+ }
+
+ $this->addButtonElements();
+ }
+}
diff --git a/application/forms/ChromeBinaryForm.php b/application/forms/ChromeBinaryForm.php
deleted file mode 100644
index 70f8751..0000000
--- a/application/forms/ChromeBinaryForm.php
+++ /dev/null
@@ -1,95 +0,0 @@
-
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-namespace Icinga\Module\Pdfexport\Forms;
-
-use Exception;
-use Icinga\Forms\ConfigForm;
-use Icinga\Module\Pdfexport\HeadlessChrome;
-use Zend_Validate_Callback;
-
-class ChromeBinaryForm extends ConfigForm
-{
- public function init()
- {
- $this->setName('pdfexport_binary');
- $this->setSubmitLabel($this->translate('Save Changes'));
- }
-
- public function createElements(array $formData)
- {
- $this->addElement('text', 'chrome_binary', [
- 'label' => $this->translate('Local Binary'),
- 'placeholder' => '/usr/bin/google-chrome',
- 'validators' => [new Zend_Validate_Callback(function ($value) {
- $chrome = (new HeadlessChrome())
- ->setBinary($value);
-
- try {
- $version = $chrome->getVersion();
- } catch (Exception $e) {
- $this->getElement('chrome_binary')->addError($e->getMessage());
- return true;
- }
-
- if ($version < 59) {
- $this->getElement('chrome_binary')->addError(sprintf(
- $this->translate(
- 'Chrome/Chromium supporting headless mode required'
- . ' which is provided since version 59. Version detected: %s'
- ),
- $version
- ));
- }
-
- return true;
- })]
- ]);
-
- $this->addElement('checkbox', 'chrome_force_temp_storage', [
- 'label' => $this->translate('Force local temp storage')
- ]);
-
- $this->addElement('text', 'chrome_host', [
- 'label' => $this->translate('Remote Host'),
- 'validators' => [new Zend_Validate_Callback(function ($value) {
- if ($value === null) {
- return true;
- }
-
- $port = $this->getValue('chrome_port') ?: 9222;
-
- $chrome = (new HeadlessChrome())
- ->setRemote($value, $port);
-
- try {
- $version = $chrome->getVersion();
- } catch (Exception $e) {
- $this->getElement('chrome_host')->addError($e->getMessage());
- return true;
- }
-
- if ($version < 59) {
- $this->getElement('chrome_host')->addError(sprintf(
- $this->translate(
- 'Chrome/Chromium supporting headless mode required'
- . ' which is provided since version 59. Version detected: %s'
- ),
- $version
- ));
- }
-
- return true;
- })]
- ]);
-
- $this->addElement('number', 'chrome_port', [
- 'label' => $this->translate('Remote Port'),
- 'placeholder' => 9222,
- 'min' => 1,
- 'max' => 65535
- ]);
- }
-}
diff --git a/application/views/scripts/config/chrome.phtml b/application/views/scripts/config/chrome.phtml
deleted file mode 100644
index 46daf07..0000000
--- a/application/views/scripts/config/chrome.phtml
+++ /dev/null
@@ -1,6 +0,0 @@
-
- = /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?>
-
-
- = /** @var \Icinga\Module\Pdfexport\Forms\ChromeBinaryForm $form */ $form ?>
-
diff --git a/configuration.php b/configuration.php
index e219f38..5997cbd 100644
--- a/configuration.php
+++ b/configuration.php
@@ -5,8 +5,8 @@
/** @var \Icinga\Application\Modules\Module $this */
-$this->provideConfigTab('chrome', array(
- 'title' => $this->translate('Configure the Chrome/Chromium connection'),
- 'label' => $this->translate('Chrome'),
- 'url' => 'config/chrome'
+$this->provideConfigTab('backends', array(
+ 'title' => $this->translate('Configure the Chrome/WebDriver connection'),
+ 'label' => $this->translate('Backends'),
+ 'url' => 'config/backends'
));
diff --git a/doc/02-Installation.md b/doc/02-Installation.md
index 733c63a..24da547 100644
--- a/doc/02-Installation.md
+++ b/doc/02-Installation.md
@@ -8,79 +8,12 @@
* [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) ≥ 1.0.0
* [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) ≥ 1.0.0
-## Google Chrome/Chromium Setup
-
-The module needs Google Chrome or Chromium supporting headless mode.
-
-### RHEL/CentOS
-
-Add the Chrome repository from Google to yum, next to EPEL.
-
-```
-yum -y install epel-release
-
-cat >/etc/yum.repos.d/google-chrome-stable.repo <
-
-Add the Chrome repository from Google to apt.
-
-```
-apt-get -y install apt-transport-https gnupg wget
-
-wget -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
-
-echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list
-
-apt-get update
-```
-
-Install Chrome.
-
-```
-apt-get install google-chrome-stable
-```
-
## Module Installation
1. Install it [like any other module](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation).
Use `pdfexport` as name.
-2. You might need to set the absolute path to the Google Chrome / Chromium
-binary, depending on your system. This can be done in
-`Configuration -> Modules -> pdfexport -> Chrome`
+2. You will need to install and configure at leaast one PDF export backend.
+See more about it in the [Configuration](03-Configuration.md) section.
This concludes the installation. PDF exports now use Google Chrome/Chromium for rendering.
-
-### Using a Remote Chrome/Chromium
-
-As an alternative to a local installation of Chrome/Chromium it is also possible
-to launch and utilize a remote instance.
-
-Just install it as described above on a different machine and configure its connection
-details in `Configuration -> Modules -> pdfexport -> Chrome`.
-
-To start a remote instance of Chrome/Chromium use the following commandline options:
-
-> google-chrome --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 --headless --keep-alive-for-test --disable-gpu --disable-dev-shm-usage --no-sandbox --bwsi --no-first-run --user-data-dir=/tmp --homedir=/tmp
-
-Note that the browser does accept any and all connection attempts without any authentication.
-Keep that in mind and let it listen on a public IP (or even on 0.0.0.0) only during tests or
-with a proper firewall in place.
diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md
new file mode 100644
index 0000000..c57a6e8
--- /dev/null
+++ b/doc/03-Configuration.md
@@ -0,0 +1,93 @@
+# Configuration
+
+The module needs to have at least one backend configured.
+All module configuration can be done in `Configuration -> Modules -> pdfexport`.
+
+The priority of the backends is determined by their order in the list.
+The first backend (lowest priority number) that is able to handle the request is used.
+
+## Using a Local Chrome/Chromium
+
+The module needs Google Chrome or Chromium supporting headless mode.
+
+### RHEL/CentOS
+
+Add the Chrome repository from Google to yum, next to EPEL.
+
+```
+yum -y install epel-release
+
+cat >/etc/yum.repos.d/google-chrome-stable.repo <
+
+Add the Chrome repository from Google to apt.
+
+```
+apt-get -y install apt-transport-https gnupg wget
+
+wget -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
+
+echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list
+
+apt-get update
+```
+
+Install Chrome.
+
+```
+apt-get install google-chrome-stable
+```
+
+## Using a Remote Chrome/Chromium
+
+As an alternative to a local installation of Chrome/Chromium it is also possible
+to launch and use a remote instance.
+
+Install it as described above on a different machine.
+
+To start a remote instance of Chrome/Chromium use the following commandline options:
+
+> google-chrome --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 --headless --keep-alive-for-test --disable-gpu --disable-dev-shm-usage --no-sandbox --bwsi --no-first-run --user-data-dir=/tmp --homedir=/tmp
+
+Note that the browser does accept any connection attempt without any authentication.
+Keep that in mind and let it listen on a public IP (or even on 0.0.0.0) only during tests or
+with a proper firewall in place.
+
+Create a new backend in `Configuration -> Modules -> pdfexport -> Backends`,
+give it a name and select `Headless Chrome (Remote)` as the backend type and configure its connection details.
+
+## Using a Chrome WebDriver Server
+
+Install your preferred Chrome WebDriver/Chromedriver server.
+See [Chromedriver Downloads Page](https://developer.chrome.com/docs/chromedriver/downloads)
+for instructions to download and install it.
+
+Create a new backend in `Configuration -> Modules -> pdfexport -> Backends`,
+give it a name and select `Chrome WebDriver` as the backend type and configure its connection details.
+
+## Using a Firefox WebDriver Server
+
+Install your preferred Firefox WebDriver/Geckodriver server.
+Check the [Geckodriver Releases Page](https://github.com/mozilla/geckodriver/releases)
+for the latest version.
+
+Create a new backend in `Configuration -> Modules -> pdfexport -> Backends`,
+give it a name and select `Firefox WebDriver` as the backend type and configure its connection details.
diff --git a/library/Pdfexport/Backend/Chromedriver.php b/library/Pdfexport/Backend/Chromedriver.php
new file mode 100644
index 0000000..c83b14c
--- /dev/null
+++ b/library/Pdfexport/Backend/Chromedriver.php
@@ -0,0 +1,89 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\Backend;
+
+use Exception;
+use Icinga\Application\Icinga;
+use Icinga\Module\Pdfexport\ChromeDevTools\ChromeDevTools;
+use Icinga\Module\Pdfexport\ChromeDevTools\Command as DevToolsCommand;
+use Icinga\Module\Pdfexport\WebDriver\Command;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Module\Pdfexport\WebDriver\Capabilities;
+use Icinga\Module\Pdfexport\WebDriver\ElementPresentCondition;
+
+class Chromedriver extends WebdriverBackend
+{
+ protected ?ChromeDevTools $dcp = null;
+
+ public function __construct(string $url)
+ {
+ parent::__construct($url, Capabilities::chrome());
+ }
+
+ protected function setContent(PrintableHtmlDocument $document): void
+ {
+ parent::setContent($document);
+
+ $module = Icinga::app()->getModuleManager()->getModule('pdfexport');
+ if (! method_exists($module, 'getJsDir')) {
+ $jsPath = join(DIRECTORY_SEPARATOR, [$module->getBaseDir(), 'public', 'js']);
+ } else {
+ $jsPath = $module->getJsDir();
+ }
+
+ $activeScripts = file_get_contents($jsPath . '/activate-scripts.js');
+
+ $this->driver->execute(
+ Command::executeScript($activeScripts),
+ );
+ $this->driver->execute(
+ Command::executeScript('new Layout().apply();'),
+ );
+ }
+
+ protected function waitForPageLoad(): void
+ {
+ parent::waitForPageLoad();
+
+ $this->driver->wait(ElementPresentCondition::byCssSelector('[data-layout-ready=yes]'));
+ }
+
+ protected function getChromeDeveloperTools(): ChromeDevTools
+ {
+ if ($this->dcp === null) {
+ $this->dcp = new ChromeDevTools($this->driver);
+ }
+ return $this->dcp;
+ }
+
+ protected function getPrintParameters(PrintableHtmlDocument $document): array
+ {
+ $parameters = [
+ 'printBackground' => true,
+ 'transferMode' => 'ReturnAsBase64',
+ ];
+
+ return array_merge(
+ $parameters,
+ $document->getPrintParameters(),
+ );
+ }
+
+ protected function printToPdf(array $printParameters): string
+ {
+ $devTools = $this->getChromeDeveloperTools();
+
+ try {
+ $devTools->execute(DevToolsCommand::enableConsole());
+ } catch (Exception $_) {
+ // Deprecated, might fail
+ }
+
+ $result = $devTools->execute(DevToolsCommand::printToPdf($printParameters));
+
+ return base64_decode($result['data']);
+ }
+}
diff --git a/library/Pdfexport/Backend/Geckodriver.php b/library/Pdfexport/Backend/Geckodriver.php
new file mode 100644
index 0000000..19f5f8e
--- /dev/null
+++ b/library/Pdfexport/Backend/Geckodriver.php
@@ -0,0 +1,22 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\Backend;
+
+use Icinga\Module\Pdfexport\WebDriver\Capabilities;
+
+class Geckodriver extends WebdriverBackend
+{
+ public function __construct(string $rul)
+ {
+ parent::__construct($rul, Capabilities::firefox());
+ }
+
+ public function supportsCoverPage(): bool
+ {
+ // Firefox generates compressed PDFs, which can't be merged by the `tcpi` libary
+ return false;
+ }
+}
diff --git a/library/Pdfexport/Backend/HeadlessChromeBackend.php b/library/Pdfexport/Backend/HeadlessChromeBackend.php
new file mode 100644
index 0000000..a4ec7cf
--- /dev/null
+++ b/library/Pdfexport/Backend/HeadlessChromeBackend.php
@@ -0,0 +1,633 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\Backend;
+
+use Exception;
+use GuzzleHttp\Client as HttpClient;
+use GuzzleHttp\Exception\ServerException;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Platform;
+use Icinga\File\Storage\StorageInterface;
+use Icinga\File\Storage\TemporaryLocalFileStorage;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Module\Pdfexport\ShellCommand;
+use LogicException;
+use Throwable;
+use WebSocket\Client;
+
+class HeadlessChromeBackend implements PfdPrintBackend
+{
+ /** @var int */
+ public const MIN_SUPPORTED_CHROME_VERSION = 59;
+
+ /**
+ * Line of stderr output identifying the websocket url
+ *
+ * The first matching group is the used port, and the second one the browser id.
+ */
+ public const DEBUG_ADDR_PATTERN = '/DevTools listening on ws:\/\/((?>\d+\.?){4}:\d+)\/devtools\/browser\/([\w-]+)/';
+
+ /** @var string */
+ public const WAIT_FOR_NETWORK = 'wait-for-network';
+
+ protected ?StorageInterface $fileStorage = null;
+
+ protected bool $useFilesystemTransfer = false;
+
+ protected ?Client $browser = null;
+
+ protected ?Client $page = null;
+
+ protected ?string $frameId;
+
+ private array $interceptedRequests = [];
+
+ private array $interceptedEvents = [];
+
+ protected ?ShellCommand $process = null;
+
+ protected ?string $socket = null;
+
+ protected ?string $browserId = null;
+
+ public function __destruct()
+ {
+ $this->close();
+ }
+
+ public static function createRemote(string $host, int $port): static
+ {
+ $instance = new self();
+ $instance->socket = "$host:$port";
+ try {
+ $result = $instance->getJsonVersion();
+
+ if (! is_array($result)) {
+ throw new Exception('Failed to determine remote chrome version via the /json/version endpoint.');
+ }
+
+ $parts = explode('/', $result['webSocketDebuggerUrl']);
+ $instance->browserId = end($parts);
+ } catch (Exception $e) {
+ Logger::warning(
+ 'Failed to connect to remote chrome: %s (%s)',
+ $instance->socket,
+ $e,
+ );
+
+ throw $e;
+ }
+
+ return $instance;
+ }
+
+ public static function createLocal(string $path, bool $useFile = false): static
+ {
+ $instance = new self();
+ $instance->useFilesystemTransfer = $useFile;
+
+ if (! file_exists($path)) {
+ throw new Exception('Local chrome binary not found: ' . $path);
+ }
+
+ $browserHome = $instance->getFileStorage()->resolvePath('HOME');
+
+ $commandLine = join(' ', [
+ escapeshellarg($path),
+ static::renderArgumentList([
+ '--bwsi',
+ '--headless',
+ '--disable-gpu',
+ '--no-sandbox',
+ '--no-first-run',
+ '--disable-dev-shm-usage',
+ '--remote-debugging-port=0',
+ '--homedir=' => $browserHome,
+ '--user-data-dir=' => $browserHome,
+ ]),
+ ]);
+
+ $env = null;
+ if (Platform::isLinux()) {
+ Logger::debug('Starting browser process: HOME=%s exec %s', $browserHome, $commandLine);
+ $env = array_merge($_ENV, ['HOME' => $browserHome]);
+ $commandLine = 'exec ' . $commandLine;
+ } else {
+ Logger::debug('Starting browser process: %s', $commandLine);
+ }
+
+ $instance->process = new ShellCommand($commandLine, false, $env);
+ $instance->process->start();
+ Logger::debug('Started browser process');
+ $instance->process->wait(function ($stdout, $stderr) use ($instance) {
+ if ($stdout !== '') {
+ Logger::debug('Caught browser stdout: %d', mb_strlen($stdout));
+ }
+ if ($stderr !== '') {
+ Logger::error('Browser process stderr: %d', mb_strlen($stderr));
+ if (preg_match(self::DEBUG_ADDR_PATTERN, trim($stderr), $matches)) {
+ $instance->socket = $matches[1];
+ $instance->browserId = $matches[2];
+
+ Logger::debug('Caught browser info socket: %s, id: %s', $instance->socket, $instance->browserId);
+ return false;
+ }
+ }
+ return true;
+ });
+
+ if ($instance->socket === null || $instance->browserId === null) {
+ throw new Exception('Could not start browser process.');
+ }
+
+ return $instance;
+ }
+
+ protected function closeLocal(): void
+ {
+ Logger::debug('Closing local chrome instance');
+
+ if ($this->process !== null) {
+ $code = $this->process->stop();
+ Logger::error("Closed local chrome with exit code %d", $code);
+ $this->process = null;
+ }
+
+ try {
+ if ($this->fileStorage !== null) {
+ $this->fileStorage = null;
+ }
+ } catch (Exception $exception) {
+ Logger::error("Failed to close local temporary file storage: " . $exception->getMessage());
+ }
+ }
+
+ /**
+ * Get the file storage
+ */
+ public function getFileStorage(): StorageInterface
+ {
+ if ($this->fileStorage === null) {
+ $this->fileStorage = new TemporaryLocalFileStorage();
+ }
+
+ return $this->fileStorage;
+ }
+
+ /**
+ * Render the given argument name-value pairs as shell-escaped string
+ */
+ public static function renderArgumentList(array $arguments): string
+ {
+ $list = [];
+
+ foreach ($arguments as $name => $value) {
+ if ($value !== null) {
+ $value = escapeshellarg($value);
+
+ if (! is_int($name)) {
+ if (str_ends_with($name, '=')) {
+ $glue = '';
+ } else {
+ $glue = ' ';
+ }
+
+ $list[] = escapeshellarg($name) . $glue . $value;
+ } else {
+ $list[] = $value;
+ }
+ } else {
+ $list[] = escapeshellarg($name);
+ }
+ }
+
+ return implode(' ', $list);
+ }
+
+ protected function getPrintParameters(PrintableHtmlDocument $document): array
+ {
+ $parameters = [
+ 'printBackground' => true,
+ 'transferMode' => 'ReturnAsBase64',
+ ];
+
+ return array_merge(
+ $parameters,
+ $document->getPrintParameters(),
+ );
+ }
+
+ public function toPdf(PrintableHtmlDocument $document): string
+ {
+ $this->setContent($document);
+ $printParameters = $this->getPrintParameters($document);
+ return $this->printToPdf($printParameters);
+ }
+
+ protected function getBrowser(): Client
+ {
+ if ($this->browser === null) {
+ $this->browser = new Client(sprintf('ws://%s/devtools/browser/%s', $this->socket, $this->browserId));
+ }
+ return $this->browser;
+ }
+
+ protected function closeBrowser(): void
+ {
+ if ($this->browser === null) {
+ return;
+ }
+
+ $this->closePage();
+
+ try {
+ $this->browser->close();
+ $this->browser = null;
+ } catch (Throwable $e) {
+ // For some reason, the browser doesn't send a response
+ Logger::debug('Failed to close browser connection: ' . $e->getMessage());
+ }
+ }
+
+ public function getPage(): Client
+ {
+ if ($this->page === null) {
+ $browser = $this->getBrowser();
+
+ // Open new tab, get its id
+ $result = $this->communicate($browser, 'Target.createTarget', [
+ 'url' => 'about:blank',
+ ]);
+ if (isset($result['targetId'])) {
+ $this->frameId = $result['targetId'];
+ } else {
+ throw new Exception('Expected target id. Got instead: ' . json_encode($result));
+ }
+
+ $this->page = new Client(sprintf('ws://%s/devtools/page/%s', $this->socket, $this->frameId));
+
+ // enable various events
+ $this->communicate($this->page, 'Log.enable');
+ $this->communicate($this->page, 'Network.enable');
+ $this->communicate($this->page, 'Page.enable');
+
+ try {
+ $this->communicate($this->page, 'Console.enable');
+ } catch (Exception) {
+ // Deprecated, might fail
+ }
+ }
+ return $this->page;
+ }
+
+ public function closePage(): void
+ {
+ if ($this->browser === null || $this->page === null) {
+ return;
+ }
+
+ // close tab
+ $result = $this->communicate($this->browser, 'Target.closeTarget', [
+ 'targetId' => $this->frameId,
+ ]);
+
+ if (! isset($result['success'])) {
+ throw new Exception('Expected close confirmation. Got instead: ' . json_encode($result));
+ }
+
+ $this->page = null;
+ $this->frameId = null;
+ }
+
+ protected function setContent(PrintableHtmlDocument $document): void
+ {
+ $page = $this->getPage();
+
+ if ($document->isEmpty()) {
+ throw new LogicException('Nothing to print');
+ }
+
+ if ($this->useFilesystemTransfer) {
+ $path = uniqid('icingaweb2-pdfexport-') . '.html';
+ $storage = $this->getFileStorage();
+
+ $storage->create($path, $document->render());
+
+ $absPath = $storage->resolvePath($path, true);
+
+ Logger::debug('Using filesystem transfer to local chrome instance. Path: ' . $absPath);
+
+ $url = "file://$absPath";
+
+ // Navigate to target
+ $result = $this->communicate($page, 'Page.navigate', [
+ 'url' => $url,
+ ]);
+
+ if (isset($result['frameId'])) {
+ $this->frameId = $result['frameId'];
+ } else {
+ throw new Exception('Expected navigation frame. Got instead: ' . json_encode($result));
+ }
+
+ // wait for the page to fully load
+ $this->waitFor(
+ $page,
+ 'Page.frameStoppedLoading',
+ [
+ 'frameId' => $this->frameId,
+ ],
+ );
+
+ try {
+ $storage->delete($path);
+ } catch (Exception $e) {
+ Logger::warning('Failed to delete file: ' . $e->getMessage());
+ }
+ } else {
+ $this->communicate($page, 'Page.setDocumentContent', [
+ 'frameId' => $this->frameId,
+ 'html' => $document->render(),
+ ]);
+ }
+
+ // wait for the page to fully load
+ $this->waitFor($page, 'Page.loadEventFired');
+
+ // Wait for network activity to finish
+ $this->waitFor($page, self::WAIT_FOR_NETWORK);
+
+ // Wait for the layout to initialize
+ if (! $document->isEmpty()) {
+ // Ensure layout scripts work in the same environment as the pdf printing itself
+ $this->communicate($page, 'Emulation.setEmulatedMedia', ['media' => 'print']);
+
+ $this->communicate($page, 'Runtime.evaluate', [
+ 'timeout' => 1000,
+ 'expression' => 'setTimeout(() => new Layout().apply(), 0)',
+ ]);
+
+ $module = Icinga::app()->getModuleManager()->getModule('pdfexport');
+ if (! method_exists($module, 'getJsDir')) {
+ $jsPath = join(DIRECTORY_SEPARATOR, [$module->getBaseDir(), 'public', 'js']);
+ } else {
+ $jsPath = $module->getJsDir();
+ }
+
+ $waitForLayout = file_get_contents($jsPath . '/wait-for-layout.js');
+
+ $promisedResult = $this->communicate($page, 'Runtime.evaluate', [
+ 'awaitPromise' => true,
+ 'returnByValue' => true,
+ 'timeout' => 1000, // Failsafe: doesn't apply to `await` it seems
+ 'expression' => $waitForLayout,
+ ]);
+ if (isset($promisedResult['exceptionDetails'])) {
+ if (isset($promisedResult['exceptionDetails']['exception']['description'])) {
+ Logger::error(
+ 'PDF layout failed to initialize: %s',
+ $promisedResult['exceptionDetails']['exception']['description'],
+ );
+ } else {
+ Logger::warning('PDF layout failed to initialize. Pages might look skewed.');
+ }
+ }
+
+ // Reset media emulation, this may prevent the real media from coming into effect?
+ $this->communicate($page, 'Emulation.setEmulatedMedia', ['media' => '']);
+ }
+ }
+
+ protected function printToPdf(array $printParameters): string
+ {
+ $page = $this->getPage();
+
+ // print pdf
+ $result = $this->communicate($page, 'Page.printToPDF', array_merge(
+ $printParameters,
+ ['transferMode' => 'ReturnAsBase64', 'printBackground' => true],
+ ));
+ if (! empty($result['data'])) {
+ $pdf = base64_decode($result['data']);
+ } else {
+ throw new Exception('Expected base64 data. Got instead: ' . json_encode($result));
+ }
+
+ return $pdf;
+ }
+
+ private function renderApiCall($method, $options = null): string
+ {
+ $data = [
+ 'id' => time(),
+ 'method' => $method,
+ 'params' => $options ?: [],
+ ];
+
+ return json_encode($data, JSON_FORCE_OBJECT);
+ }
+
+ private function parseApiResponse(string $payload)
+ {
+ $data = json_decode($payload, true);
+ if (isset($data['method']) || isset($data['result'])) {
+ return $data;
+ } elseif (isset($data['error'])) {
+ throw new Exception(sprintf(
+ 'Error response (%s): %s',
+ $data['error']['code'],
+ $data['error']['message'],
+ ));
+ } else {
+ throw new Exception(sprintf('Unknown response received: %s', $payload));
+ }
+ }
+
+ private function registerEvent($method, $params): void
+ {
+ if (Logger::getInstance()->getLevel() === Logger::DEBUG) {
+ $shortenValues = function ($params) use (&$shortenValues) {
+ foreach ($params as &$value) {
+ if (is_array($value)) {
+ $value = $shortenValues($value);
+ } elseif (is_string($value)) {
+ $shortened = substr($value, 0, 256);
+ if ($shortened !== $value) {
+ $value = $shortened . '...';
+ }
+ }
+ }
+
+ return $params;
+ };
+ $shortenedParams = $shortenValues($params);
+
+ Logger::debug(
+ 'Received CDP event: %s(%s)',
+ $method,
+ join(',', array_map(function ($param) use ($shortenedParams) {
+ return $param . '=' . json_encode($shortenedParams[$param]);
+ }, array_keys($shortenedParams))),
+ );
+ }
+
+ if ($method === 'Network.requestWillBeSent') {
+ $this->interceptedRequests[$params['requestId']] = $params;
+ } elseif ($method === 'Network.loadingFinished') {
+ unset($this->interceptedRequests[$params['requestId']]);
+ } elseif ($method === 'Network.loadingFailed') {
+ $requestData = $this->interceptedRequests[$params['requestId']];
+ unset($this->interceptedRequests[$params['requestId']]);
+
+ Logger::error(
+ 'Headless Chrome was unable to complete a request to "%s". Error: %s',
+ $requestData['request']['url'],
+ $params['errorText'],
+ );
+ } else {
+ $this->interceptedEvents[] = ['method' => $method, 'params' => $params];
+ }
+ }
+
+ private function communicate(Client $ws, $method, $params = null)
+ {
+ Logger::debug('Transmitting CDP call: %s(%s)', $method, $params ? join(',', array_keys($params)) : '');
+ $ws->text($this->renderApiCall($method, $params));
+
+ do {
+ $response = $this->parseApiResponse($ws->receive()->getContent());
+ $gotEvent = isset($response['method']);
+
+ if ($gotEvent) {
+ $this->registerEvent($response['method'], $response['params']);
+ }
+ } while ($gotEvent);
+
+ Logger::debug('Received CDP result: %s', empty($response['result'])
+ ? 'none'
+ : join(',', array_keys($response['result'])));
+
+ return $response['result'];
+ }
+
+ private function waitFor(Client $ws, $eventName, ?array $expectedParams = null)
+ {
+ if ($eventName !== self::WAIT_FOR_NETWORK) {
+ Logger::debug(
+ 'Awaiting CDP event: %s(%s)',
+ $eventName,
+ $expectedParams ? join(',', array_keys($expectedParams)) : '',
+ );
+ } elseif (empty($this->interceptedRequests)) {
+ return null;
+ }
+
+ $wait = true;
+ $interceptedPos = -1;
+
+ $params = null;
+ do {
+ if (isset($this->interceptedEvents[++$interceptedPos])) {
+ $response = $this->interceptedEvents[$interceptedPos];
+ $intercepted = true;
+ } else {
+ $response = $this->parseApiResponse($ws->receive()->getContent());
+ $intercepted = false;
+ }
+
+ if (isset($response['method'])) {
+ $method = $response['method'];
+ $params = $response['params'];
+
+ if (! $intercepted) {
+ $this->registerEvent($method, $params);
+ }
+
+ if ($eventName === self::WAIT_FOR_NETWORK) {
+ $wait = ! empty($this->interceptedRequests);
+ } elseif ($method === $eventName) {
+ if ($expectedParams !== null) {
+ $diff = array_intersect_assoc($params, $expectedParams);
+ $wait = empty($diff);
+ } else {
+ $wait = false;
+ }
+ }
+
+ if (! $wait && $intercepted) {
+ unset($this->interceptedEvents[$interceptedPos]);
+ }
+ }
+ } while ($wait);
+
+ return $params;
+ }
+
+ /**
+ * Fetch result from the /json/version API endpoint
+ */
+ protected function getJsonVersion(): bool|array
+ {
+ $client = new HttpClient();
+
+ try {
+ $response = $client->request('GET', sprintf('http://%s/json/version', $this->socket));
+ } catch (ServerException $e) {
+ // Check if we've run into the host header security change, and re-run the request with no host header.
+ // ref: https://issues.chromium.org/issues/40090537
+ if (str_contains($e->getMessage(), 'Host header is specified and is not an IP address or localhost.')) {
+ $response = $client->request(
+ 'GET',
+ sprintf('http://%s/json/version', $this->socket),
+ ['headers' => ['Host' => null]],
+ );
+ } else {
+ throw $e;
+ }
+ }
+
+ if ($response->getStatusCode() !== 200) {
+ return false;
+ }
+
+ return json_decode($response->getBody(), true);
+ }
+
+ public function getVersion(): int
+ {
+ $version = $this->getJsonVersion();
+
+ if (! isset($version['Browser'])) {
+ throw new Exception("Invalid Version Json");
+ }
+
+ preg_match('/Chrome\/([0-9]+)/', $version['Browser'], $matches);
+
+ if (! isset($matches[1])) {
+ throw new Exception("Malformed Chrome Version String: " . $version['Browser']);
+ }
+
+ return (int) $matches[1];
+ }
+
+ public function isSupported(): bool
+ {
+ return $this->getVersion() >= self::MIN_SUPPORTED_CHROME_VERSION;
+ }
+
+ public function close(): void
+ {
+ $this->closeBrowser();
+ $this->closeBrowser();
+ $this->closeLocal();
+ }
+
+ public function supportsCoverPage(): bool
+ {
+ return true;
+ }
+}
diff --git a/library/Pdfexport/Backend/PfdPrintBackend.php b/library/Pdfexport/Backend/PfdPrintBackend.php
new file mode 100644
index 0000000..75b03df
--- /dev/null
+++ b/library/Pdfexport/Backend/PfdPrintBackend.php
@@ -0,0 +1,19 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\Backend;
+
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+
+interface PfdPrintBackend
+{
+ public function toPdf(PrintableHtmlDocument $document): string;
+
+ public function isSupported(): bool;
+
+ public function supportsCoverPage(): bool;
+
+ public function close(): void;
+}
diff --git a/library/Pdfexport/Backend/WebdriverBackend.php b/library/Pdfexport/Backend/WebdriverBackend.php
new file mode 100644
index 0000000..d16e080
--- /dev/null
+++ b/library/Pdfexport/Backend/WebdriverBackend.php
@@ -0,0 +1,93 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\Backend;
+
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Module\Pdfexport\WebDriver\Capabilities;
+use Icinga\Module\Pdfexport\WebDriver\ElementPresentCondition;
+use Icinga\Module\Pdfexport\WebDriver\WebDriver;
+use Icinga\Module\Pdfexport\WebDriver\Command;
+
+class WebdriverBackend implements PfdPrintBackend
+{
+ protected WebDriver $driver;
+
+ public function __construct(
+ string $url,
+ Capabilities $capabilities,
+ ) {
+ $this->driver = WebDriver::create($url, $capabilities);
+ }
+
+ public function __destruct()
+ {
+ $this->close();
+ }
+
+ protected function setContent(PrintableHtmlDocument $document): void
+ {
+ // This is horribly ugly, but it works for all browser backends
+ $encoded = base64_encode($document);
+ $this->driver->execute(
+ Command::executeScript('document.head.remove();'),
+ );
+ $this->driver->execute(
+ Command::executeScript("document.body.outerHTML = atob('$encoded');"),
+ );
+ }
+
+ protected function waitForPageLoad(): void
+ {
+ $this->driver->wait(ElementPresentCondition::byTagName('body'));
+ }
+
+ protected function getPrintParameters(PrintableHtmlDocument $document): array
+ {
+ $parameters = [
+ 'background' => true,
+ ];
+
+ return array_merge(
+ $parameters,
+ $document->getPrintParametersForWebdriver(),
+ );
+ }
+
+ protected function printToPdf(array $printParameters): string
+ {
+ $result = $this->driver->execute(
+ Command::printPage($printParameters),
+ );
+
+ return base64_decode($result);
+ }
+
+ public function toPdf(PrintableHtmlDocument $document): string
+ {
+ $this->setContent($document);
+ $this->waitForPageLoad();
+
+ $printParameters = $this->getPrintParameters($document);
+
+ return $this->printToPdf($printParameters);
+ }
+
+ public function isSupported(): bool
+ {
+ // TODO: Come up with a check
+ return true;
+ }
+
+ public function close(): void
+ {
+ $this->driver->quit();
+ }
+
+ public function supportsCoverPage(): bool
+ {
+ return true;
+ }
+}
diff --git a/library/Pdfexport/BackendLocator.php b/library/Pdfexport/BackendLocator.php
new file mode 100644
index 0000000..4067971
--- /dev/null
+++ b/library/Pdfexport/BackendLocator.php
@@ -0,0 +1,183 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Module\Pdfexport\Backend\Chromedriver;
+use Icinga\Module\Pdfexport\Backend\Geckodriver;
+use Icinga\Module\Pdfexport\Backend\HeadlessChromeBackend;
+use Icinga\Module\Pdfexport\Backend\PfdPrintBackend;
+
+/**
+ * Class implementing the traversal and fallback logic for the PDF backend selection.
+ */
+class BackendLocator
+{
+ /**
+ * The currently connected backend.
+ * This is used to avoid re-connecting to the same backend multiple times.
+ * @var PfdPrintBackend|null
+ */
+ protected ?PfdPrintBackend $backend = null;
+
+ /**
+ * Get the first supported backend from the configuration which responded with a successful connection.
+ * First, in this context means the backend with the lowest priority.
+ * Note: This method caches the first successful backend and reuses it for subsequent calls.
+ * @return PfdPrintBackend|null the first supported backend or null if none is available
+ */
+ public function getFirstSupportedBackend(): ?PfdPrintBackend
+ {
+ if ($this->backend !== null && $this->backend->isSupported()) {
+ return $this->backend;
+ }
+
+ $this->backend = null;
+ $sorted = [];
+ foreach (Config::module('pdfexport') as $section => $configs) {
+ $priority = (int) $configs->get('priority', 100);
+ $sorted[$section] = $priority;
+ }
+
+ asort($sorted);
+
+ foreach ($sorted as $section => $priority) {
+ $this->backend = $this->getSingleBackend($section);
+ if ($this->backend === null) {
+ continue;
+ }
+ return $this->backend;
+ }
+
+ return null;
+ }
+
+ /**
+ * Create and connect to a WebDriver backend.
+ * The backend is identified by the 'type' configuration option.
+ * @param string $section The configuration section to use for the backend.
+ *
+ * @return PfdPrintBackend|null The created and connected backend or null if the backend could not be created.
+ */
+ protected function connectToWebDriver(string $section): ?PfdPrintBackend
+ {
+ $config = Config::module('pdfexport');
+ try {
+ $host = $config->get($section, 'host');
+ if ($host === null) {
+ return null;
+ }
+ $port = $config->get($section, 'port', 4444);
+ $url = "$host:$port";
+ $type = $config->get($section, 'type');
+ $backend = match ($type) {
+ 'chrome_webdriver' => new Chromedriver($url),
+ 'firefox_webdriver' => new Geckodriver($url),
+ default => throw new Exception("Invalid webdriver type $type"),
+ };
+ Logger::info("Connected WebDriver Backend: $section");
+ return $backend;
+ } catch (Exception $e) {
+ Logger::warning("Webdriver connection failed! backend: $section, error: " . $e->getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Connect to a remote HeadlessChrome backend.
+ * @param string $section the configuration section to use for the backend
+ *
+ * @return PfdPrintBackend|null the created and connected backend or null if the backend could not be created
+ */
+ protected function connectToRemoteChrome(string $section): ?PfdPrintBackend
+ {
+ $config = Config::module('pdfexport');
+ try {
+ $host = $config->get($section, 'host');
+ if ($host === null) {
+ return null;
+ }
+ $port = $config->get($section, 'port', 9222);
+ $backend = HeadlessChromeBackend::createRemote(
+ $host,
+ $port,
+ );
+ Logger::info("Connected WebDriver Backend: $section");
+ return $backend;
+ } catch (Exception $e) {
+ Logger::warning(
+ "Error while creating remote HeadlessChrome! backend: $section, error: " . $e->getMessage(),
+ );
+ }
+ return null;
+ }
+
+ /**
+ * Connect to a local HeadlessChrome backend.
+ * @param string $section the configuration section to use for the backend
+ *
+ * @return PfdPrintBackend|null the created and connected backend or null if the backend could not be created
+ */
+ protected function connectToLocalChrome(string $section): ?PfdPrintBackend
+ {
+ $config = Config::module('pdfexport');
+ $binary = $config->get($section, 'binary', '/usr/bin/google-chrome');
+ try {
+ if ($binary === null) {
+ return null;
+ }
+ $backend = HeadlessChromeBackend::createLocal(
+ $binary,
+ Config::module('pdfexport')->get('chrome', 'force_temp_storage', '0') === '1',
+ );
+ Logger::info("Connected local chrome Backend: $section");
+ return $backend;
+ } catch (Exception $e) {
+ Logger::warning(
+ "Error while creating HeadlessChrome backend: $section, path: $binary, error:" . $e->getMessage(),
+ );
+ }
+ return null;
+ }
+
+ /**
+ * Create and connect to a single backend.
+ * The type of the backend is determined by the 'type' configuration option.
+ * @param $section string the configuration section to use for the backend
+ *
+ * @return PfdPrintBackend|null the created and connected backend or null if the backend could not be created
+ */
+ protected function getSingleBackend(string $section): ?PfdPrintBackend
+ {
+ $config = Config::module('pdfexport');
+ if (! $config->hasSection($section)) {
+ return null;
+ }
+
+ Logger::info("Connecting to backend $section.");
+
+ $type = $config->get($section, 'type');
+ $backend = match ($type) {
+ 'local_chrome' => $this->connectToLocalChrome($section),
+ 'remote_chrome' => $this->connectToRemoteChrome($section),
+ default => $this->connectToWebDriver($section),
+ };
+
+ if ($backend === null) {
+ Logger::warning("Failed to connect to backend: $section");
+ return null;
+ }
+
+ if (! $backend->isSupported()) {
+ Logger::info("Connected backend $section reports that it doesn't support generating PDFs");
+ return null;
+ }
+
+ return $backend;
+ }
+}
diff --git a/library/Pdfexport/ChromeDevTools/ChromeDevTools.php b/library/Pdfexport/ChromeDevTools/ChromeDevTools.php
new file mode 100644
index 0000000..24c9102
--- /dev/null
+++ b/library/Pdfexport/ChromeDevTools/ChromeDevTools.php
@@ -0,0 +1,33 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\ChromeDevTools;
+
+use Icinga\Module\Pdfexport\WebDriver\CustomCommand;
+use Icinga\Module\Pdfexport\WebDriver\WebDriver;
+
+class ChromeDevTools
+{
+ public function __construct(
+ protected WebDriver $driver,
+ ) {
+ }
+
+ public function execute(Command $command): mixed
+ {
+ $params = [
+ 'cmd' => $command->name,
+ 'params' => $command->parameters,
+ ];
+
+ $customCommand = new CustomCommand(
+ 'POST',
+ '/session/:sessionId/goog/cdp/execute',
+ $params,
+ );
+
+ return $this->driver->execute($customCommand);
+ }
+}
diff --git a/library/Pdfexport/ChromeDevTools/Command.php b/library/Pdfexport/ChromeDevTools/Command.php
new file mode 100644
index 0000000..781003b
--- /dev/null
+++ b/library/Pdfexport/ChromeDevTools/Command.php
@@ -0,0 +1,25 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\ChromeDevTools;
+
+readonly class Command
+{
+ public function __construct(
+ public string $name,
+ public array $parameters = [],
+ ) {
+ }
+
+ public static function enableConsole(): static
+ {
+ return new static('Console.enable');
+ }
+
+ public static function printToPdf(array $printParameters): static
+ {
+ return new static('Page.printToPDF', $printParameters);
+ }
+}
diff --git a/library/Pdfexport/HeadlessChrome.php b/library/Pdfexport/HeadlessChrome.php
deleted file mode 100644
index f40b6a2..0000000
--- a/library/Pdfexport/HeadlessChrome.php
+++ /dev/null
@@ -1,780 +0,0 @@
-
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-namespace Icinga\Module\Pdfexport;
-
-use Exception;
-use Icinga\Application\Logger;
-use Icinga\Application\Platform;
-use Icinga\File\Storage\StorageInterface;
-use Icinga\File\Storage\TemporaryLocalFileStorage;
-use ipl\Html\HtmlString;
-use LogicException;
-use React\ChildProcess\Process;
-use React\EventLoop\Loop;
-use React\EventLoop\TimerInterface;
-use React\Promise;
-use React\Promise\PromiseInterface;
-use Throwable;
-use WebSocket\Client;
-
-class HeadlessChrome
-{
- /**
- * Line of stderr output identifying the websocket url
- *
- * First matching group is the used port and the second one the browser id.
- */
- public const DEBUG_ADDR_PATTERN = '/DevTools listening on ws:\/\/((?>\d+\.?){4}:\d+)\/devtools\/browser\/([\w-]+)/';
-
- /** @var string */
- public const WAIT_FOR_NETWORK = 'wait-for-network';
-
- /** @var string Javascript Promise to wait for layout initialization */
- public const WAIT_FOR_LAYOUT = << {
- let timeoutId = setTimeout(() => reject('fail'), 10000);
-
- if (document.documentElement.dataset.layoutReady === 'yes') {
- clearTimeout(timeoutId);
- fulfill(null);
- return;
- }
-
- document.addEventListener('layout-ready', e => {
- clearTimeout(timeoutId);
- fulfill(e.detail);
- }, {
- once: true
- });
-})
-JS;
-
- /** @var string Path to the Chrome binary */
- protected $binary;
-
- /** @var array Host and port to the remote Chrome */
- protected $remote;
-
- /**
- * The document to print
- *
- * @var PrintableHtmlDocument
- */
- protected $document;
-
- /** @var string Target Url */
- protected $url;
-
- /** @var StorageInterface */
- protected $fileStorage;
-
- /** @var array */
- private $interceptedRequests = [];
-
- /** @var array */
- private $interceptedEvents = [];
-
- /**
- * Get the path to the Chrome binary
- *
- * @return string
- */
- public function getBinary()
- {
- return $this->binary;
- }
-
- /**
- * Set the path to the Chrome binary
- *
- * @param string $binary
- *
- * @return $this
- */
- public function setBinary($binary)
- {
- $this->binary = $binary;
-
- return $this;
- }
-
- /**
- * Get host and port combination of the remote chrome
- *
- * @return array
- */
- public function getRemote()
- {
- return $this->remote;
- }
-
- /**
- * Set host and port combination of a remote chrome
- *
- * @param string $host
- * @param int $port
- *
- * @return $this
- */
- public function setRemote($host, $port)
- {
- $this->remote = [$host, $port];
-
- return $this;
- }
-
- /**
- * Get the target Url
- *
- * @return string
- */
- public function getUrl()
- {
- return $this->url;
- }
-
- /**
- * Set the target Url
- *
- * @param string $url
- *
- * @return $this
- */
- public function setUrl($url)
- {
- $this->url = $url;
-
- return $this;
- }
-
- /**
- * Get the file storage
- *
- * @return StorageInterface
- */
- public function getFileStorage()
- {
- if ($this->fileStorage === null) {
- $this->fileStorage = new TemporaryLocalFileStorage();
- }
-
- return $this->fileStorage;
- }
-
- /**
- * Set the file storage
- *
- * @param StorageInterface $fileStorage
- *
- * @return $this
- */
- public function setFileStorage($fileStorage)
- {
- $this->fileStorage = $fileStorage;
-
- return $this;
- }
-
- /**
- * Render the given argument name-value pairs as shell-escaped string
- *
- * @param array $arguments
- *
- * @return string
- */
- public static function renderArgumentList(array $arguments)
- {
- $list = [];
-
- foreach ($arguments as $name => $value) {
- if ($value !== null) {
- $value = escapeshellarg($value);
-
- if (! is_int($name)) {
- if (substr($name, -1) === '=') {
- $glue = '';
- } else {
- $glue = ' ';
- }
-
- $list[] = escapeshellarg($name) . $glue . $value;
- } else {
- $list[] = $value;
- }
- } else {
- $list[] = escapeshellarg($name);
- }
- }
-
- return implode(' ', $list);
- }
-
- /**
- * Use the given HTML as input
- *
- * @param string|PrintableHtmlDocument $html
- * @param bool $asFile
- * @return $this
- */
- public function fromHtml($html, $asFile = false)
- {
- if ($html instanceof PrintableHtmlDocument) {
- $this->document = $html;
- } else {
- $this->document = (new PrintableHtmlDocument())
- ->setContent(HtmlString::create($html));
- }
-
- if ($asFile) {
- $path = uniqid('icingaweb2-pdfexport-') . '.html';
- $storage = $this->getFileStorage();
-
- $storage->create($path, $this->document->render());
-
- $path = $storage->resolvePath($path, true);
-
- $this->setUrl("file://$path");
- }
-
- return $this;
- }
-
- /**
- * Generate a PDF raw string asynchronously.
- *
- * @return PromiseInterface
- */
- public function asyncToPdf(): PromiseInterface
- {
- $deferred = new Promise\Deferred();
- Loop::futureTick(function () use ($deferred) {
- switch (true) {
- case $this->remote !== null:
- try {
- $result = $this->jsonVersion($this->remote[0], $this->remote[1]);
- if (is_array($result)) {
- $parts = explode('/', $result['webSocketDebuggerUrl']);
- $pdf = $this->printToPDF(
- join(':', $this->remote),
- end($parts),
- ! $this->document->isEmpty() ? $this->document->getPrintParameters() : []
- );
- break;
- }
- } catch (Exception $e) {
- if ($this->binary == null) {
- $deferred->reject($e);
- return;
- }
-
- Logger::warning(
- 'Failed to connect to remote chrome: %s:%d (%s)',
- $this->remote[0],
- $this->remote[1],
- $e
- );
- }
-
- // Reject the promise if we didn't get the expected output from the /json/version endpoint.
- if ($this->binary === null) {
- $deferred->reject(
- new Exception('Failed to determine remote chrome version via the /json/version endpoint.')
- );
- return;
- }
-
- // Fallback to the local binary if a remote chrome is unavailable
- case $this->binary !== null:
- $browserHome = $this->getFileStorage()->resolvePath('HOME');
- $commandLine = join(' ', [
- escapeshellarg($this->getBinary()),
- static::renderArgumentList([
- '--bwsi',
- '--headless',
- '--disable-gpu',
- '--no-sandbox',
- '--no-first-run',
- '--disable-dev-shm-usage',
- '--remote-debugging-port=0',
- '--homedir=' => $browserHome,
- '--user-data-dir=' => $browserHome
- ])
- ]);
-
- if (Platform::isLinux()) {
- Logger::debug('Starting browser process: HOME=%s exec %s', $browserHome, $commandLine);
- $chrome = new Process('exec ' . $commandLine, null, ['HOME' => $browserHome]);
- } else {
- Logger::debug('Starting browser process: %s', $commandLine);
- $chrome = new Process($commandLine);
- }
-
- $killer = Loop::addTimer(10, function (TimerInterface $timer) use ($chrome, $deferred) {
- $chrome->terminate(6); // SIGABRT
-
- Logger::error(
- 'Browser timed out after %d seconds without the expected output',
- $timer->getInterval()
- );
-
- $deferred->reject(
- new Exception(
- 'Received empty response or none at all from browser.'
- . ' Please check the logs for further details.'
- )
- );
- });
-
- $chrome->start();
-
- $chrome->stderr->on('data', function ($chunk) use ($chrome, $deferred, $killer) {
- Logger::debug('Caught browser output: %s', $chunk);
-
- if (preg_match(self::DEBUG_ADDR_PATTERN, trim($chunk), $matches)) {
- Loop::cancelTimer($killer);
-
- try {
- $pdf = $this->printToPDF(
- $matches[1],
- $matches[2],
- ! $this->document->isEmpty() ? $this->document->getPrintParameters() : []
- );
- } catch (Exception $e) {
- Logger::error('Failed to print PDF. An error occurred: %s', $e);
- }
-
- $chrome->terminate();
-
- if (! empty($pdf)) {
- $deferred->resolve($pdf);
- } else {
- $deferred->reject(
- new Exception(
- 'Received empty response or none at all from browser.'
- . ' Please check the logs for further details.'
- )
- );
- }
- }
- });
-
- $chrome->on('exit', function ($exitCode, $signal) use ($killer) {
- Loop::cancelTimer($killer);
-
- Logger::debug('Browser terminated by signal %d and exited with code %d', $signal, $exitCode);
-
- // Browser is either timed out (after 10s) and the promise should have already been rejected,
- // or it is terminated using its terminate() method, in which case the promise is also already
- // resolved/rejected. So, we don't need to resolve/reject the promise here.
- });
-
- return;
- }
-
- if (! empty($pdf)) {
- $deferred->resolve($pdf);
- } else {
- $deferred->reject(
- new Exception(
- 'Received empty response or none at all from browser.'
- . ' Please check the logs for further details.'
- )
- );
- }
- });
-
- return $deferred->promise();
- }
-
- /**
- * Export to PDF
- *
- * @return string
- * @throws Exception
- */
- public function toPdf()
- {
- $pdf = '';
-
- $this->asyncToPdf()->then(function (string $newPdf) use (&$pdf) {
- $pdf = $newPdf;
- });
-
- Loop::run();
-
- return $pdf;
- }
-
- /**
- * Export to PDF and save as file on disk
- *
- * @return string The path to the file on disk
- */
- public function savePdf()
- {
- $path = uniqid('icingaweb2-pdfexport-') . '.pdf';
-
- $storage = $this->getFileStorage();
- $storage->create($path, '');
-
- $path = $storage->resolvePath($path, true);
- file_put_contents($path, $this->toPdf());
-
- return $path;
- }
-
- private function printToPDF($socket, $browserId, array $parameters)
- {
- $browser = new Client(sprintf('ws://%s/devtools/browser/%s', $socket, $browserId));
-
- // Open new tab, get its id
- $result = $this->communicate($browser, 'Target.createTarget', [
- 'url' => 'about:blank'
- ]);
- if (isset($result['targetId'])) {
- $targetId = $result['targetId'];
- } else {
- throw new Exception('Expected target id. Got instead: ' . json_encode($result));
- }
-
- $page = new Client(sprintf('ws://%s/devtools/page/%s', $socket, $targetId), ['timeout' => 300]);
-
- // enable various events
- $this->communicate($page, 'Log.enable');
- $this->communicate($page, 'Network.enable');
- $this->communicate($page, 'Page.enable');
-
- try {
- $this->communicate($page, 'Console.enable');
- } catch (Exception $_) {
- // Deprecated, might fail
- }
-
- if (($url = $this->getUrl()) !== null) {
- // Navigate to target
- $result = $this->communicate($page, 'Page.navigate', [
- 'url' => $url
- ]);
- if (isset($result['frameId'])) {
- $frameId = $result['frameId'];
- } else {
- throw new Exception('Expected navigation frame. Got instead: ' . json_encode($result));
- }
-
- // wait for page to fully load
- $this->waitFor($page, 'Page.frameStoppedLoading', ['frameId' => $frameId]);
- } elseif (! $this->document->isEmpty()) {
- // If there's no url to load transfer the document's content directly
- $this->communicate($page, 'Page.setDocumentContent', [
- 'frameId' => $targetId,
- 'html' => $this->document->render()
- ]);
-
- // wait for page to fully load
- $this->waitFor($page, 'Page.loadEventFired');
- } else {
- throw new LogicException('Nothing to print');
- }
-
- // Wait for network activity to finish
- $this->waitFor($page, self::WAIT_FOR_NETWORK);
-
- // Wait for layout to initialize
- if (! $this->document->isEmpty()) {
- // Ensure layout scripts work in the same environment as the pdf printing itself
- $this->communicate($page, 'Emulation.setEmulatedMedia', ['media' => 'print']);
-
- $this->communicate($page, 'Runtime.evaluate', [
- 'timeout' => 1000,
- 'expression' => 'setTimeout(() => new Layout().apply(), 0)'
- ]);
-
- $promisedResult = $this->communicate($page, 'Runtime.evaluate', [
- 'awaitPromise' => true,
- 'returnByValue' => true,
- 'timeout' => 1000, // Failsafe, doesn't apply to `await` it seems
- 'expression' => static::WAIT_FOR_LAYOUT
- ]);
- if (isset($promisedResult['exceptionDetails'])) {
- if (isset($promisedResult['exceptionDetails']['exception']['description'])) {
- Logger::error(
- 'PDF layout failed to initialize: %s',
- $promisedResult['exceptionDetails']['exception']['description']
- );
- } else {
- Logger::warning('PDF layout failed to initialize. Pages might look skewed.');
- }
- }
-
- // Reset media emulation, this may prevent the real media from coming into effect?
- $this->communicate($page, 'Emulation.setEmulatedMedia', ['media' => '']);
- }
-
- // print pdf
- $result = $this->communicate($page, 'Page.printToPDF', array_merge(
- $parameters,
- ['transferMode' => 'ReturnAsBase64', 'printBackground' => true]
- ));
- if (! empty($result['data'])) {
- $pdf = base64_decode($result['data']);
- } else {
- throw new Exception('Expected base64 data. Got instead: ' . json_encode($result));
- }
-
- // close tab
- $result = $this->communicate($browser, 'Target.closeTarget', [
- 'targetId' => $targetId
- ]);
- if (! isset($result['success'])) {
- throw new Exception('Expected close confirmation. Got instead: ' . json_encode($result));
- }
-
- try {
- $browser->close();
- } catch (Throwable $e) {
- // For some reason, the browser doesn't send a response
- Logger::debug(sprintf('Failed to close browser connection: ' . $e->getMessage()));
- }
-
- return $pdf;
- }
-
- private function renderApiCall($method, $options = null): string
- {
- $data = [
- 'id' => time(),
- 'method' => $method,
- 'params' => $options ?: []
- ];
-
- return json_encode($data, JSON_FORCE_OBJECT);
- }
-
- private function parseApiResponse(string $payload)
- {
- $data = json_decode($payload, true);
- if (isset($data['method']) || isset($data['result'])) {
- return $data;
- } elseif (isset($data['error'])) {
- throw new Exception(sprintf(
- 'Error response (%s): %s',
- $data['error']['code'],
- $data['error']['message']
- ));
- } else {
- throw new Exception(sprintf('Unknown response received: %s', $payload));
- }
- }
-
- private function registerEvent($method, $params)
- {
- if (Logger::getInstance()->getLevel() === Logger::DEBUG) {
- $shortenValues = function ($params) use (&$shortenValues) {
- foreach ($params as &$value) {
- if (is_array($value)) {
- $value = $shortenValues($value);
- } elseif (is_string($value)) {
- $shortened = substr($value, 0, 256);
- if ($shortened !== $value) {
- $value = $shortened . '...';
- }
- }
- }
-
- return $params;
- };
- $shortenedParams = $shortenValues($params);
-
- Logger::debug(
- 'Received CDP event: %s(%s)',
- $method,
- join(',', array_map(function ($param) use ($shortenedParams) {
- return $param . '=' . json_encode($shortenedParams[$param]);
- }, array_keys($shortenedParams)))
- );
- }
-
- if ($method === 'Network.requestWillBeSent') {
- $this->interceptedRequests[$params['requestId']] = $params;
- } elseif ($method === 'Network.loadingFinished') {
- unset($this->interceptedRequests[$params['requestId']]);
- } elseif ($method === 'Network.loadingFailed') {
- $requestData = $this->interceptedRequests[$params['requestId']];
- unset($this->interceptedRequests[$params['requestId']]);
-
- Logger::error(
- 'Headless Chrome was unable to complete a request to "%s". Error: %s',
- $requestData['request']['url'],
- $params['errorText']
- );
- } else {
- $this->interceptedEvents[] = ['method' => $method, 'params' => $params];
- }
- }
-
- private function communicate(Client $ws, $method, $params = null)
- {
- Logger::debug('Transmitting CDP call: %s(%s)', $method, $params ? join(',', array_keys($params)) : '');
- $ws->text($this->renderApiCall($method, $params));
-
- do {
- $response = $this->parseApiResponse($ws->receive()->getContent());
- $gotEvent = isset($response['method']);
-
- if ($gotEvent) {
- $this->registerEvent($response['method'], $response['params']);
- }
- } while ($gotEvent);
-
- Logger::debug('Received CDP result: %s', empty($response['result'])
- ? 'none'
- : join(',', array_keys($response['result'])));
-
- return $response['result'];
- }
-
- private function waitFor(Client $ws, $eventName, ?array $expectedParams = null)
- {
- if ($eventName !== self::WAIT_FOR_NETWORK) {
- Logger::debug(
- 'Awaiting CDP event: %s(%s)',
- $eventName,
- $expectedParams ? join(',', array_keys($expectedParams)) : ''
- );
- } elseif (empty($this->interceptedRequests)) {
- return null;
- }
-
- $wait = true;
- $interceptedPos = -1;
-
- $params = null;
- do {
- if (isset($this->interceptedEvents[++$interceptedPos])) {
- $response = $this->interceptedEvents[$interceptedPos];
- $intercepted = true;
- } else {
- $response = $this->parseApiResponse($ws->receive()->getContent());
- $intercepted = false;
- }
-
- if (isset($response['method'])) {
- $method = $response['method'];
- $params = $response['params'];
-
- if (! $intercepted) {
- $this->registerEvent($method, $params);
- }
-
- if ($eventName === self::WAIT_FOR_NETWORK) {
- $wait = ! empty($this->interceptedRequests);
- } elseif ($method === $eventName) {
- if ($expectedParams !== null) {
- $diff = array_intersect_assoc($params, $expectedParams);
- $wait = empty($diff);
- } else {
- $wait = false;
- }
- }
-
- if (! $wait && $intercepted) {
- unset($this->interceptedEvents[$interceptedPos]);
- }
- }
- } while ($wait);
-
- return $params;
- }
-
- /**
- * Get the major version number of Chrome or false on failure
- *
- * @return int|false
- *
- * @throws Exception
- */
- public function getVersion()
- {
- switch (true) {
- case $this->remote !== null:
- try {
- $result = $this->jsonVersion($this->remote[0], $this->remote[1]);
- $version = $result['Browser'];
- break;
- } catch (Exception $e) {
- if ($this->binary === null) {
- throw $e;
- } else {
- Logger::warning(
- 'Failed to connect to remote chrome: %s:%d (%s)',
- $this->remote[0],
- $this->remote[1],
- $e
- );
- }
- }
-
- // Fallback to the local binary if a remote chrome is unavailable
- case $this->binary !== null:
- $command = new ShellCommand(
- escapeshellarg($this->getBinary()) . ' ' . static::renderArgumentList(['--version']),
- false
- );
-
- $output = $command->execute();
-
- if ($command->getExitCode() !== 0) {
- throw new \Exception($output->stderr);
- }
-
- $version = $output->stdout;
- break;
- default:
- throw new LogicException('Set a binary or remote first');
- }
-
- if (preg_match('/(\d+)\.[\d.]+/', $version, $match)) {
- return (int) $match[1];
- }
-
- return false;
- }
-
- /**
- * Fetch result from the /json/version API endpoint
- *
- * @param string $host
- * @param int $port
- *
- * @return bool|array
- */
- protected function jsonVersion($host, $port)
- {
- $client = new \GuzzleHttp\Client();
-
- try {
- $response = $client->request('GET', sprintf('http://%s:%s/json/version', $host, $port));
- } catch (\GuzzleHttp\Exception\ServerException $e) {
- // Check if we've run into the host header security change, and re-run the request with no host header.
- // ref: https://issues.chromium.org/issues/40090537
- if (strstr($e->getMessage(), 'Host header is specified and is not an IP address or localhost.')) {
- $response = $client->request(
- 'GET',
- sprintf('http://%s:%s/json/version', $host, $port),
- ['headers' => ['Host' => null]]
- );
- } else {
- throw $e;
- }
- }
-
- if ($response->getStatusCode() !== 200) {
- return false;
- }
-
- return json_decode($response->getBody(), true);
- }
-}
diff --git a/library/Pdfexport/PrintStyleSheet.php b/library/Pdfexport/PrintStyleSheet.php
index 48ec2f2..34039ad 100644
--- a/library/Pdfexport/PrintStyleSheet.php
+++ b/library/Pdfexport/PrintStyleSheet.php
@@ -16,7 +16,7 @@ protected function collect()
$this->lessCompiler->setTheme(join(DIRECTORY_SEPARATOR, [
Icinga::app()->getModuleManager()->getModule('pdfexport')->getCssDir(),
- 'print.less'
+ 'print.less',
]));
if (method_exists($this->lessCompiler, 'setThemeMode')) {
diff --git a/library/Pdfexport/PrintableHtmlDocument.php b/library/Pdfexport/PrintableHtmlDocument.php
index 4cd59f5..f0b294e 100644
--- a/library/Pdfexport/PrintableHtmlDocument.php
+++ b/library/Pdfexport/PrintableHtmlDocument.php
@@ -55,98 +55,80 @@ class PrintableHtmlDocument extends BaseHtmlElement
}
CSS;
- /** @var string Document title */
- protected $title;
+ /** Document title */
+ protected ?string $title = null;
/**
* Paper orientation
*
* Defaults to false.
- *
- * @var ?bool
*/
- protected $landscape;
+ protected ?bool $landscape = null;
/**
* Print background graphics
*
* Defaults to false.
- *
- * @var ?bool
*/
- protected $printBackground;
+ protected ?bool $printBackground = null;
/**
* Scale of the webpage rendering
*
* Defaults to 1.
- *
- * @var ?float
*/
- protected $scale;
+ protected ?float $scale = null;
/**
* Paper width in inches
*
* Defaults to 8.5 inches.
- *
- * @var ?float
*/
- protected $paperWidth;
+ protected ?float $paperWidth = null;
/**
* Paper height in inches
*
* Defaults to 11 inches.
- *
- * @var ?float
*/
- protected $paperHeight;
+ protected ?float $paperHeight = null;
/**
* Top margin in inches
*
* Defaults to 1cm (~0.4 inches).
- *
- * @var ?float
*/
- protected $marginTop;
+ protected ?float $marginTop = null;
/**
* Bottom margin in inches
*
* Defaults to 1cm (~0.4 inches).
- *
- * @var ?float
*/
- protected $marginBottom;
+ protected ?float $marginBottom = null;
/**
* Left margin in inches
*
* Defaults to 1cm (~0.4 inches).
- *
- * @var ?float
*/
- protected $marginLeft;
+ protected ?float $marginLeft = null;
/**
* Right margin in inches
*
* Defaults to 1cm (~0.4 inches).
- *
- * @var ?float
*/
- protected $marginRight;
+ protected ?float $marginRight = null;
/**
* Paper ranges to print, e.g., '1-5, 8, 11-13'
*
* Defaults to the empty string, which means print all pages
*
- * @var ?string
+ * @var string
*/
- protected $pageRanges;
+ protected string $pageRanges = "";
/**
* Page height in pixels
@@ -154,15 +136,13 @@ class PrintableHtmlDocument extends BaseHtmlElement
* Minus the default vertical margins, this is 1035.
* If the vertical margins are zero, it's 1160.
* Whether there's a header or footer doesn't matter in any case.
- *
- * @var int
*/
- protected $pagePixelHeight = 1035;
+ protected int $pagePixelHeight = 1035;
/**
* HTML template for the print header
*
- * Should be valid HTML markup with following classes used to inject printing values into them:
+ * Should be valid HTML markup with the following classes used to inject printing values into them:
* * date: formatted print date
* * title: document title
* * url: document location
@@ -174,15 +154,13 @@ class PrintableHtmlDocument extends BaseHtmlElement
* Note that the header cannot exceed a height of 21px regardless of the margin's height or document's scale.
* With the default style, this height is separated by three lines, each accommodating 7px.
* Use `span`'s for single line text and `p`'s for multiline text.
- *
- * @var ?ValidHtml
*/
- protected $headerTemplate;
+ protected ?ValidHtml $headerTemplate = null;
/**
* HTML template for the print footer
*
- * Should be valid HTML markup with following classes used to inject printing values into them:
+ * Should be valid HTML markup with the following classes used to inject printing values into them:
* * date: formatted print date
* * title: document title
* * url: document location
@@ -194,26 +172,20 @@ class PrintableHtmlDocument extends BaseHtmlElement
* Note that the footer cannot exceed a height of 21px regardless of the margin's height or document's scale.
* With the default style, this height is separated by three lines, each accommodating 7px.
* Use `span`'s for single line text and `p`'s for multiline text.
- *
- * @var ?ValidHtml
*/
- protected $footerTemplate;
+ protected ?ValidHtml $footerTemplate = null;
/**
* HTML for the cover page
- *
- * @var ValidHtml
*/
- protected $coverPage;
+ protected ?ValidHtml $coverPage = null;
/**
- * Whether or not to prefer page size as defined by css
+ * Whether to prefer page size as defined by CSS
*
* Defaults to false, in which case the content will be scaled to fit the paper size.
- *
- * @var ?bool
*/
- protected $preferCSSPageSize;
+ protected ?bool $preferCSSPageSize = null;
protected $tag = 'body';
@@ -222,7 +194,7 @@ class PrintableHtmlDocument extends BaseHtmlElement
*
* @return string
*/
- public function getTitle()
+ public function getTitle(): string
{
return $this->title;
}
@@ -234,7 +206,7 @@ public function getTitle()
*
* @return $this
*/
- public function setTitle($title)
+ public function setTitle(string $title): static
{
$this->title = $title;
@@ -248,7 +220,7 @@ public function setTitle($title)
*
* @return $this
*/
- public function setHeader(ValidHtml $header)
+ public function setHeader(ValidHtml $header): static
{
$this->headerTemplate = $header;
@@ -262,7 +234,7 @@ public function setHeader(ValidHtml $header)
*
* @return $this
*/
- public function setFooter(ValidHtml $footer)
+ public function setFooter(ValidHtml $footer): static
{
$this->footerTemplate = $footer;
@@ -274,7 +246,7 @@ public function setFooter(ValidHtml $footer)
*
* @return ValidHtml|null
*/
- public function getCoverPage()
+ public function getCoverPage(): ?ValidHtml
{
return $this->coverPage;
}
@@ -286,7 +258,7 @@ public function getCoverPage()
*
* @return $this
*/
- public function setCoverPage(ValidHtml $coverPage)
+ public function setCoverPage(ValidHtml $coverPage): static
{
$this->coverPage = $coverPage;
@@ -298,7 +270,7 @@ public function setCoverPage(ValidHtml $coverPage)
*
* @return $this
*/
- public function removeMargins()
+ public function removeMargins(): static
{
$this->marginBottom = 0;
$this->marginLeft = 0;
@@ -311,7 +283,7 @@ public function removeMargins()
/**
* Finalize document to be printed
*/
- protected function assemble()
+ protected function assemble(): void
{
$this->setWrapper(new HtmlElement(
'html',
@@ -322,11 +294,11 @@ protected function assemble()
new HtmlElement(
'title',
null,
- Text::create($this->title)
+ Text::create($this->title),
),
$this->createStylesheet(),
- $this->createLayoutScript()
- )
+ $this->createLayoutScript(),
+ ),
));
$this->getAttributes()->registerAttributeCallback('data-content-height', function () {
@@ -342,7 +314,67 @@ protected function assemble()
*
* @return array
*/
- public function getPrintParameters()
+ public function getPrintParametersForWebdriver(): array
+ {
+ $parameters = [];
+
+ if (isset($this->landscape)) {
+ $parameters['landscape'] = $this->landscape ? 'landscape' : 'portrait';
+ }
+
+ if (isset($this->printBackground)) {
+ $parameters['background'] = $this->printBackground;
+ }
+
+ if (isset($this->scale)) {
+ $parameters['scale'] = $this->scale;
+ }
+
+ // TODO: Validate width & height
+ if (isset($this->paperWidth)) {
+ $parameters['paperWidth'] = $this->paperWidth;
+ }
+
+ if (isset($this->paperHeight)) {
+ $parameters['paperHeight'] = $this->paperHeight;
+ }
+
+ // TODO: Validate margins
+ if (isset($this->marginTop)) {
+ $parameters['marginTop'] = $this->marginTop;
+ }
+
+ if (isset($this->marginBottom)) {
+ $parameters['marginBottom'] = $this->marginBottom;
+ }
+
+ if (isset($this->marginLeft)) {
+ $parameters['marginLeft'] = $this->marginLeft;
+ }
+
+ if (isset($this->marginRight)) {
+ $parameters['marginRight'] = $this->marginRight;
+ }
+
+ if (isset($this->pageRanges)) {
+ $parameters['pageRanges'] = explode(',', $this->pageRanges);
+ }
+
+ // Note: Header and footer aren't supported for webdriver
+
+ if (isset($this->preferCSSPageSize)) {
+ $parameters['preferCSSPageSize'] = $this->preferCSSPageSize;
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Get the parameters for Page.printToPDF
+ *
+ * @return array
+ */
+ public function getPrintParameters(): array
{
$parameters = [];
@@ -427,17 +459,17 @@ protected function createStylesheet(): ValidHtml
$css = preg_replace_callback(
'~(?<=url\()[\'"]?([^(\'"]*)[\'"]?(?=\))~',
function ($matches) use ($app) {
- if (substr($matches[1], 0, 3) !== '../') {
+ if (! str_starts_with($matches[1], '../')) {
return $matches[1];
}
$path = substr($matches[1], 3);
- if (substr($path, 0, 4) === 'lib/') {
+ if (str_starts_with($path, 'lib/')) {
$assetPath = substr($path, 4);
$library = null;
foreach ($app->getLibraries() as $candidate) {
- if (substr($assetPath, 0, strlen($candidate->getName())) === $candidate->getName()) {
+ if (str_starts_with($assetPath, $candidate->getName())) {
$library = $candidate;
$assetPath = ltrim(substr($assetPath, strlen($candidate->getName())), '/');
break;
@@ -449,8 +481,8 @@ function ($matches) use ($app) {
}
$path = $library->getStaticAssetPath() . DIRECTORY_SEPARATOR . $assetPath;
- } elseif (substr($matches[1], 0, 14) === '../static/img?') {
- list($_, $query) = explode('?', $matches[1], 2);
+ } elseif (str_starts_with($matches[1], '../static/img?')) {
+ [$_, $query] = explode('?', $matches[1], 2);
$params = UrlParams::fromQueryString($query);
if (! $app->getModuleManager()->hasEnabled($params->get('module_name'))) {
return $matches[1];
@@ -479,7 +511,7 @@ function ($matches) use ($app) {
return "'data:$mimeType; base64, " . base64_encode($fileContent) . "'";
},
- (new PrintStyleSheet())->render(true)
+ (new PrintStyleSheet())->render(true),
);
return new HtmlElement('style', null, HtmlString::create($css));
@@ -505,7 +537,7 @@ protected function createLayoutScript(): ValidHtml
return new HtmlElement(
'script',
Attributes::create(['type' => 'application/javascript']),
- HtmlString::create($layoutJS)
+ HtmlString::create($layoutJS),
);
}
@@ -519,9 +551,9 @@ protected function createHeader(): ValidHtml
return (new HtmlDocument())
->addHtml(
new HtmlElement('style', null, HtmlString::create(
- static::DEFAULT_HEADER_FOOTER_STYLE
+ static::DEFAULT_HEADER_FOOTER_STYLE,
)),
- new HtmlElement('header', null, $this->headerTemplate)
+ new HtmlElement('header', null, $this->headerTemplate),
);
}
@@ -535,9 +567,9 @@ protected function createFooter(): ValidHtml
return (new HtmlDocument())
->addHtml(
new HtmlElement('style', null, HtmlString::create(
- static::DEFAULT_HEADER_FOOTER_STYLE
+ static::DEFAULT_HEADER_FOOTER_STYLE,
)),
- new HtmlElement('footer', null, $this->footerTemplate)
+ new HtmlElement('footer', null, $this->footerTemplate),
);
}
}
diff --git a/library/Pdfexport/ProvidedHook/Pdfexport.php b/library/Pdfexport/ProvidedHook/Pdfexport.php
index a4903e6..fd08f37 100644
--- a/library/Pdfexport/ProvidedHook/Pdfexport.php
+++ b/library/Pdfexport/ProvidedHook/Pdfexport.php
@@ -6,136 +6,112 @@
namespace Icinga\Module\Pdfexport\ProvidedHook;
use Exception;
-use Icinga\Application\Config;
use Icinga\Application\Hook;
use Icinga\Application\Hook\PdfexportHook;
use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
use Icinga\Application\Web;
use Icinga\File\Storage\TemporaryLocalFileStorage;
-use Icinga\Module\Pdfexport\HeadlessChrome;
+use Icinga\Module\Pdfexport\BackendLocator;
use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\Html\ValidHtml;
use Karriere\PdfMerge\PdfMerge;
-use React\Promise\PromiseInterface;
+use RuntimeException;
class Pdfexport extends PdfexportHook
{
+ protected ?BackendLocator $locator = null;
+
+ /**
+ * Get the first hook.
+ * Note: This function is the exact same as the one if the base class.
+ * It can be removed after we decide to remove compatibility with current
+ * reporting (1.1) and icingaweb2 (2.13) versions.
+ *
+ * @return static
+ */
public static function first()
{
- $pdfexport = null;
-
- if (Hook::has('Pdfexport')) {
- $pdfexport = Hook::first('Pdfexport');
-
- if (! $pdfexport->isSupported()) {
- throw new Exception(
- sprintf("Can't export: %s does not support exporting PDFs", get_class($pdfexport))
- );
- }
+ if (! Hook::has('Pdfexport')) {
+ throw new RuntimeException('No PDF exporter available');
}
-
- if (! $pdfexport) {
- throw new Exception("Can't export: No module found which provides PDF export");
+ $pdfexport = Hook::first('Pdfexport');
+ if (! $pdfexport->isSupported()) {
+ throw new RuntimeException('PDF exporter is not supported');
}
-
return $pdfexport;
}
- public static function getBinary()
- {
- return Config::module('pdfexport')->get('chrome', 'binary', '/usr/bin/google-chrome');
- }
-
- public static function getForceTempStorage()
- {
- return (bool) Config::module('pdfexport')->get('chrome', 'force_temp_storage', '0');
- }
-
- public static function getHost()
- {
- return Config::module('pdfexport')->get('chrome', 'host');
- }
-
- public static function getPort()
+ /**
+ * Get the backend locator instance, creating it if necessary
+ * @return BackendLocator
+ */
+ protected function getLocator(): BackendLocator
{
- return Config::module('pdfexport')->get('chrome', 'port', 9222);
+ if (! $this->locator) {
+ $this->locator = new BackendLocator();
+ }
+ return $this->locator;
}
public function isSupported()
{
+ $locator = $this->getLocator();
try {
- return $this->chrome()->getVersion() >= 59;
+ $backend = $locator->getFirstSupportedBackend();
+ return $backend !== null;
} catch (Exception $e) {
+ Logger::warning("No supported PDF backend available.");
return false;
}
}
- public function htmlToPdf($html)
+ public function streamPdfFromHtml($html, $filename)
{
- // Keep reference to the chrome object because it is using temp files which are automatically removed when
- // the object is destructed
- $chrome = $this->chrome();
-
- $pdf = $chrome->fromHtml($html, static::getForceTempStorage())->toPdf();
-
- if ($html instanceof PrintableHtmlDocument && ($coverPage = $html->getCoverPage()) !== null) {
- $coverPagePdf = $chrome
- ->fromHtml(
- (new PrintableHtmlDocument())
- ->add($coverPage)
- ->addAttributes($html->getAttributes())
- ->removeMargins(),
- static::getForceTempStorage()
- )
- ->toPdf();
-
- $pdf = $this->mergePdfs($coverPagePdf, $pdf);
- }
+ $pdf = $this->htmlToPdf($html);
+ $filename = basename($filename, '.pdf') . '.pdf';
- return $pdf;
+ $this->emit($pdf, $filename);
+
+ exit;
}
- /**
- * Transforms the given printable html document/string asynchronously to PDF.
- *
- * @param PrintableHtmlDocument|string $html
- *
- * @return PromiseInterface
- */
- public function asyncHtmlToPdf($html): PromiseInterface
+ public function htmlToPdf($html)
{
- // Keep reference to the chrome object because it is using temp files which are automatically removed when
- // the object is destructed
- $chrome = $this->chrome();
-
- $pdfPromise = $chrome->fromHtml($html, static::getForceTempStorage())->asyncToPdf();
-
- if ($html instanceof PrintableHtmlDocument && ($coverPage = $html->getCoverPage()) !== null) {
- /** @var PromiseInterface $pdfPromise */
- $pdfPromise = $pdfPromise->then(function (string $pdf) use ($chrome, $html, $coverPage) {
- return $chrome->fromHtml(
- (new PrintableHtmlDocument())
- ->add($coverPage)
- ->addAttributes($html->getAttributes())
- ->removeMargins(),
- static::getForceTempStorage()
- )->asyncToPdf()->then(
- function (string $coverPagePdf) use ($pdf) {
- return $this->mergePdfs($coverPagePdf, $pdf);
- }
- );
- });
+ $document = $this->getPrintableHtmlDocument($html);
+
+ $locator = $this->getLocator();
+ $backend = $locator->getFirstSupportedBackend();
+ if ($backend === null) {
+ Logger::warning("No supported PDF backend available.");
}
- return $pdfPromise;
- }
+ $pdf = $backend->toPdf($document);
- public function streamPdfFromHtml($html, $filename)
- {
- $filename = basename($filename, '.pdf') . '.pdf';
+ if ($html instanceof PrintableHtmlDocument && $backend->supportsCoverPage()) {
+ $coverPage = $html->getCoverPage();
+ if ($coverPage !== null) {
+ $coverPageDocument = $this->getPrintableHtmlDocument($coverPage);
+ $coverPageDocument->addAttributes($html->getAttributes());
+ $coverPageDocument->removeMargins();
- // Generate the PDF before changing the response headers to properly handle and display errors in the UI.
- $pdf = $this->htmlToPdf($html);
+ $coverPagePdf = $backend->toPdf($coverPageDocument);
+
+ $backend->close();
+
+ $pdf = $this->mergePdfs($coverPagePdf, $pdf);
+ }
+ }
+ $backend->close();
+ unset($coverPage);
+
+ return $pdf;
+ }
+
+ protected function emit(string $pdf, string $filename): void
+ {
/** @var Web $app */
$app = Icinga::app();
$app->getResponse()
@@ -143,25 +119,15 @@ public function streamPdfFromHtml($html, $filename)
->setHeader('Content-Disposition', "inline; filename=\"$filename\"", true)
->setBody($pdf)
->sendResponse();
-
- exit;
}
- /**
- * Create an instance of HeadlessChrome from configuration
- *
- * @return HeadlessChrome
- */
- protected function chrome()
+ protected function getPrintableHtmlDocument(ValidHtml $html): PrintableHtmlDocument
{
- $chrome = new HeadlessChrome();
- $chrome->setBinary(static::getBinary());
-
- if (($host = static::getHost()) !== null) {
- $chrome->setRemote($host, static::getPort());
+ if ($html instanceof PrintableHtmlDocument) {
+ return $html;
}
-
- return $chrome;
+ return (new PrintableHtmlDocument())
+ ->setContent(HtmlString::create($html));
}
protected function mergePdfs(string ...$pdfs): string
diff --git a/library/Pdfexport/ShellCommand.php b/library/Pdfexport/ShellCommand.php
index 9025560..ec37233 100644
--- a/library/Pdfexport/ShellCommand.php
+++ b/library/Pdfexport/ShellCommand.php
@@ -5,28 +5,45 @@
namespace Icinga\Module\Pdfexport;
+use Exception;
+
+/**
+ * Abstraction for a running shell command.
+ */
class ShellCommand
{
/** @var string Command to execute */
- protected $command;
+ protected string $command;
+
+ /** @var ?int Exit code of the command */
+ protected ?int $exitCode = null;
- /** @var int Exit code of the command */
- protected $exitCode;
+ /** @var array|null Environment variables */
+ protected ?array $env;
/** @var ?resource Process resource */
protected $resource;
+ /** @var object|null Named pipe resources */
+ protected ?object $namedPipes;
+
+ /** @var string collected stdout */
+ protected string $stdout;
+
+ /** @var string collected stderr */
+ protected string $stderr;
+
/**
* Create a new command
*
- * @param string $command The command to execute
- * @param bool $escape Whether to escape the command
+ * @param string $command The command to execute
+ * @param bool $escape Whether to escape the command
+ * @param array|null $env Environment variables
*/
- public function __construct($command, $escape = true)
+ public function __construct(string $command, bool $escape = true, ?array $env = null)
{
- $command = (string) $command;
-
$this->command = $escape ? escapeshellcmd($command) : $command;
+ $this->env = $env;
}
/**
@@ -34,7 +51,7 @@ public function __construct($command, $escape = true)
*
* @return int
*/
- public function getExitCode()
+ public function getExitCode(): int
{
return $this->exitCode;
}
@@ -44,7 +61,7 @@ public function getExitCode()
*
* @return object
*/
- public function getStatus()
+ public function getStatus(): object
{
$status = (object) proc_get_status($this->resource);
if ($status->running === false && $this->exitCode === null) {
@@ -57,63 +74,82 @@ public function getStatus()
}
/**
- * Execute the command
- *
- * @return object
+ * Run the command
*
- * @throws \Exception
+ * @return void
+ * @throws Exception
*/
- public function execute()
+ public function start(): void
{
if ($this->resource !== null) {
- throw new \Exception('Command already started');
+ throw new Exception('Command already started');
}
$descriptors = [
['pipe', 'r'], // stdin
['pipe', 'w'], // stdout
- ['pipe', 'w'] // stderr
+ ['pipe', 'w'], // stderr
];
$this->resource = proc_open(
$this->command,
$descriptors,
- $pipes
+ $pipes,
+ null,
+ $this->env,
);
if (! is_resource($this->resource)) {
- throw new \Exception(sprintf(
+ throw new Exception(sprintf(
"Can't fork '%s'",
- $this->command
+ $this->command,
));
}
- $namedpipes = (object) [
- 'stdin' => &$pipes[0],
- 'stdout' => &$pipes[1],
- 'stderr' => &$pipes[2]
+ $this->namedPipes = (object) [
+ 'stdin' => &$pipes[0],
+ 'stdout' => &$pipes[1],
+ 'stderr' => &$pipes[2],
];
- fclose($namedpipes->stdin);
+ fclose($this->namedPipes->stdin);
+ }
+
+ /**
+ * Capture stdout and stderr of the command.
+ * This function will block until the command exits or the optional callback returns false.
+ *
+ * @param callable|null $callback A callback function that will be called with the captured stdout and stderr.
+ * The callback should return true to continue waiting, false to stop waiting.
+ *
+ * @return void
+ * @throws Exception
+ */
+ public function wait($callback = null): void
+ {
+ if ($this->resource === null) {
+ throw new Exception('Command not started');
+ }
- $read = [$namedpipes->stderr, $namedpipes->stdout];
+ $read = [$this->namedPipes->stderr, $this->namedPipes->stdout];
$origRead = $read;
- $write = null; // stdin not handled
+ // stdin not handled
+ $write = null;
$except = null;
- $stdout = '';
- $stderr = '';
+ $this->stdout = '';
+ $this->stderr = '';
- stream_set_blocking($namedpipes->stdout, false); // non-blocking
- stream_set_blocking($namedpipes->stderr, false);
+ stream_set_blocking($this->namedPipes->stdout, false);
+ stream_set_blocking($this->namedPipes->stderr, false);
while (stream_select($read, $write, $except, 0, 20000) !== false) {
foreach ($read as $pipe) {
- if ($pipe === $namedpipes->stdout) {
- $stdout .= stream_get_contents($pipe);
+ if ($pipe === $this->namedPipes->stdout) {
+ $this->stdout .= stream_get_contents($pipe);
}
- if ($pipe === $namedpipes->stderr) {
- $stderr .= stream_get_contents($pipe);
+ if ($pipe === $this->namedPipes->stderr) {
+ $this->stderr .= stream_get_contents($pipe);
}
}
@@ -129,10 +165,31 @@ public function execute()
// Reset pipes
$read = $origRead;
+
+ if ($callback !== null) {
+ $continue = call_user_func($callback, $this->stdout, $this->stderr);
+ if ($continue === false) {
+ break;
+ }
+ }
}
+ }
- fclose($namedpipes->stderr);
- fclose($namedpipes->stdout);
+ /**
+ * Stop running command and return exit code
+ *
+ * @return int exit code
+ * @throws Exception
+ */
+ public function stop(): int
+ {
+ if ($this->resource === null) {
+ throw new Exception('Command not started');
+ }
+ fclose($this->namedPipes->stderr);
+ fclose($this->namedPipes->stdout);
+
+ proc_terminate($this->resource);
$exitCode = proc_close($this->resource);
if ($this->exitCode === null) {
@@ -140,10 +197,8 @@ public function execute()
}
$this->resource = null;
+ $this->namedPipes = null;
- return (object) [
- 'stdout' => $stdout,
- 'stderr' => $stderr
- ];
+ return $exitCode;
}
}
diff --git a/library/Pdfexport/WebDriver/Capabilities.php b/library/Pdfexport/WebDriver/Capabilities.php
new file mode 100644
index 0000000..30f2be5
--- /dev/null
+++ b/library/Pdfexport/WebDriver/Capabilities.php
@@ -0,0 +1,129 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+/**
+ * A container for WebDriver capabilities.
+ * @link https://www.w3.org/TR/webdriver/#capabilities
+ */
+class Capabilities
+{
+ /** @var array */
+ private static array $ossToW3c = [
+ 'platform' => 'platformName',
+ 'version' => 'browserVersion',
+ 'acceptSslCerts' => 'acceptInsecureCerts',
+ ];
+
+ /**
+ * Construct a new Capabilities set.
+ * @param array $capabilities The capabilities to set.
+ */
+ public function __construct(
+ protected array $capabilities = [],
+ ) {
+ }
+
+ /**
+ * Create a new Capabilities set with the default capabilities for Chrome.
+ * @return static
+ */
+ public static function chrome(): static
+ {
+ return new static([
+ 'browserName' => 'chrome',
+ 'platform' => 'ANY',
+ ]);
+ }
+
+ /**
+ * Create a new Capabilities set with the default capabilities for Firefox.
+ * @return static
+ */
+ public static function firefox(): static
+ {
+ return new static([
+ 'browserName' => 'firefox',
+ 'platform' => 'ANY',
+ 'moz:firefoxOptions' => [
+ 'prefs' => [
+ 'reader.parse-on-load.enabled' => false,
+ 'devtools.jsonview.enabled' => false,
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Convert the capabilities to a W3C-compatible array.
+ * @return array
+ */
+ public function toW3cCompatibleArray(): array
+ {
+ $allowedW3cCapabilities = [
+ 'browserName',
+ 'browserVersion',
+ 'platformName',
+ 'acceptInsecureCerts',
+ 'pageLoadStrategy',
+ 'proxy',
+ 'setWindowRect',
+ 'timeouts',
+ 'strictFileInteractability',
+ 'unhandledPromptBehavior',
+ ];
+
+ $capabilities = $this->toArray();
+ $w3cCapabilities = [];
+
+ foreach ($capabilities as $capabilityKey => $capabilityValue) {
+ if (in_array($capabilityKey, $allowedW3cCapabilities, true)) {
+ $w3cCapabilities[$capabilityKey] = $capabilityValue;
+ continue;
+ }
+
+ if (array_key_exists($capabilityKey, self::$ossToW3c)) {
+ if ($capabilityKey === 'platform') {
+ if ($capabilityValue === 'ANY') {
+ continue;
+ }
+
+ $w3cCapabilities[self::$ossToW3c[$capabilityKey]] = mb_strtolower($capabilityValue);
+ } else {
+ $w3cCapabilities[self::$ossToW3c[$capabilityKey]] = $capabilityValue;
+ }
+ }
+
+ if (mb_strpos($capabilityKey, ':') !== false) {
+ $w3cCapabilities[$capabilityKey] = $capabilityValue;
+ }
+ }
+
+ if (array_key_exists('goog:chromeOptions', $capabilities)) {
+ $w3cCapabilities['goog:chromeOptions'] = $capabilities['goog:chromeOptions'];
+ }
+
+ if (array_key_exists('firefox_profile', $capabilities)) {
+ if (
+ ! array_key_exists('moz:firefoxOptions', $capabilities)
+ || ! array_key_exists('profile', $capabilities['moz:firefoxOptions'])
+ ) {
+ $w3cCapabilities['moz:firefoxOptions']['profile'] = $capabilities['firefox_profile'];
+ }
+ }
+
+ return $w3cCapabilities;
+ }
+
+ /**
+ * Get the raw capabilities array. This is not necessarily W3C-compatible.
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->capabilities;
+ }
+}
diff --git a/library/Pdfexport/WebDriver/Command.php b/library/Pdfexport/WebDriver/Command.php
new file mode 100644
index 0000000..603d5c1
--- /dev/null
+++ b/library/Pdfexport/WebDriver/Command.php
@@ -0,0 +1,122 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+/**
+ * Wrapper class for standard WebDriver commands.
+ */
+class Command implements CommandInterface
+{
+ /**
+ * Create a new command.
+ * @param CommandName $name the standardized command instance.
+ * @param array $parameters the parameters for the command
+ */
+ public function __construct(
+ protected CommandName $name,
+ protected array $parameters = [],
+ ) {
+ }
+
+ /**
+ * Convenience method to create a new ExecuteScript command.
+ * @param string $script the raw JavaScript to execute in the browser
+ * @param array $arguments the arguments to pass to the script
+ *
+ * @return static
+ */
+ public static function executeScript(string $script, array $arguments = []): static
+ {
+ $params = [
+ 'script' => $script,
+ 'args' => static::prepareScriptArguments($arguments),
+ ];
+
+ return new static(CommandName::ExecuteScript, $params);
+ }
+
+ /**
+ * Convenience method to create a new GetPageSource command.
+ * This command is useful for debugging purposes and will return the entire HTML source of the current page.
+ * @return static
+ */
+ public static function getPageSource(): static
+ {
+ return new static(CommandName::GetPageSource);
+ }
+
+ /**
+ * Find an element on the page using the specified method and value.
+ * @param string $method
+ * @param string $value
+ *
+ * @return static
+ */
+ public static function findElement(string $method, string $value): static
+ {
+ return new static(CommandName::FindElement, [
+ 'using' => $method,
+ 'value' => $value,
+ ]);
+ }
+
+ /**
+ * Format script arguments for use in ExecuteScript commands.
+ * This method recursively converts nested arrays into JSON objects.
+ * @param array $arguments
+ *
+ * @return array
+ */
+ protected static function prepareScriptArguments(array $arguments): array
+ {
+ $args = [];
+ foreach ($arguments as $key => $value) {
+ if (is_array($value)) {
+ $args[$key] = static::prepareScriptArguments($value);
+ } else {
+ $args[$key] = $value;
+ }
+ }
+
+ return $args;
+ }
+
+ /**
+ * Print a page using the specified print parameters.
+ * The result of this command is a PDF file that is base64-encoded.
+ * @param array $printParameters
+ *
+ * @return static
+ */
+ public static function printPage(array $printParameters): static
+ {
+ return new static(CommandName::PrintPage, $printParameters);
+ }
+
+ public function getPath(): string
+ {
+ return $this->name->getPath();
+ }
+
+ public function getMethod(): string
+ {
+ return $this->name->getMethod();
+ }
+
+ public function getParameters(): array
+ {
+ return $this->parameters;
+ }
+
+ /**
+ * Return the underlying standardized command name.
+ * @return CommandName
+ */
+ public function getName(): CommandName
+ {
+ return $this->name;
+ }
+}
diff --git a/library/Pdfexport/WebDriver/CommandExecutor.php b/library/Pdfexport/WebDriver/CommandExecutor.php
new file mode 100644
index 0000000..98ffdd8
--- /dev/null
+++ b/library/Pdfexport/WebDriver/CommandExecutor.php
@@ -0,0 +1,133 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+use Exception;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use Icinga\Application\Logger;
+use RuntimeException;
+
+/**
+ * An abstraction layer for executing WebDriver commands.
+ */
+class CommandExecutor
+{
+ protected const DEFAULT_HEADERS = [
+ 'Content-Type' => 'application/json;charset=UTF-8',
+ 'Accept' => 'application/json',
+ ];
+
+ protected ?Client $client = null;
+
+ /**
+ * Construct a new CommandExecutor given a base URL
+ * @param string $url the base URL to use for executing commands
+ * @param float|null $timeout the timeout in seconds for each command
+ */
+ public function __construct(
+ protected string $url,
+ protected ?float $timeout = 10,
+ ) {
+ $this->client = new Client();
+ }
+
+ /**
+ * Execute a WebDriver command.
+ * @param string|null $sessionId the session ID to use for the command, required for each command except
+ * `newSession`
+ * @param CommandInterface $command the command to execute
+ *
+ * @return Response
+ * @throws GuzzleException
+ */
+ public function execute(?string $sessionId, CommandInterface $command): Response
+ {
+ $method = $command->getMethod();
+ $path = $command->getPath();
+ if (str_contains($path, ':sessionId')) {
+ if ($sessionId === null) {
+ throw new RuntimeException('Session ID is not set');
+ }
+ $path = str_replace(':sessionId', $sessionId, $path);
+ }
+ $params = $command->getParameters();
+ foreach ($params as $name => $value) {
+ if (str_starts_with($name, ':')) {
+ $path = str_replace($name, $value, $path);
+ unset($params[$name]);
+ }
+ }
+
+ if (is_array($params) && ! empty($params) && $method !== 'POST') {
+ throw new RuntimeException('Invalid HTTP method');
+ }
+
+ if (
+ $command instanceof Command
+ && $command->getName() === CommandName::NewSession
+ ) {
+ $method = 'POST';
+ }
+
+ $headers = static::DEFAULT_HEADERS;
+
+ if (in_array($method, ['POST', 'PUT'], true)) {
+ unset($headers['expect']);
+ }
+
+ if (is_array($params) && ! empty($params)) {
+ $body = json_encode($params);
+ } else {
+ $body = '{}';
+ }
+
+ $options = [
+ 'headers' => $headers,
+ 'expect' => false,
+ 'body' => $body,
+ 'http_errors' => false,
+ 'timeout' => $this->timeout ?? 0,
+ ];
+
+ $response = $this->client->request($method, $this->url . $path, $options);
+
+ $results = json_decode($response->getBody()->getContents(), true);
+
+ if ($results === null && json_last_error() !== JSON_ERROR_NONE) {
+ throw new RuntimeException(json_last_error_msg());
+ }
+
+ if (! is_array($results)) {
+ throw new RuntimeException('Invalid response');
+ }
+
+ $value = $results['value'] ?? null;
+ $message = $results['message'] ?? null;
+
+ if (is_array($value) && array_key_exists('sessionId', $value)) {
+ $sessionId = $value['sessionId'];
+ } elseif (isset($results['sessionId'])) {
+ $sessionId = $results['sessionId'];
+ }
+
+ if (isset($value['error'])) {
+ Logger::error(print_r($value, true));
+ throw new Exception(sprintf(
+ "Error in command response: %s - %s",
+ $value['error'],
+ $value['message'] ?? "Unknown error",
+ ));
+ }
+
+ $status = $results['status'] ?? 0;
+ if ($status !== 0) {
+ throw new Exception($message, $status);
+ }
+
+ return new Response($sessionId, $status, $value);
+ }
+}
diff --git a/library/Pdfexport/WebDriver/CommandInterface.php b/library/Pdfexport/WebDriver/CommandInterface.php
new file mode 100644
index 0000000..f3a1422
--- /dev/null
+++ b/library/Pdfexport/WebDriver/CommandInterface.php
@@ -0,0 +1,31 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+/**
+ * Interface for WebDriver commands.
+ * @link https://www.w3.org/TR/webdriver/#commands
+ */
+interface CommandInterface
+{
+ /**
+ * Get the path to the endpoint of the command.
+ * @return string
+ */
+ public function getPath(): string;
+
+ /**
+ * The HTTP method to use for the command.
+ * @return string
+ */
+ public function getMethod(): string;
+
+ /**
+ * Get the command parameters.
+ * @return array
+ */
+ public function getParameters(): array;
+}
diff --git a/library/Pdfexport/WebDriver/CommandName.php b/library/Pdfexport/WebDriver/CommandName.php
new file mode 100644
index 0000000..29d62fd
--- /dev/null
+++ b/library/Pdfexport/WebDriver/CommandName.php
@@ -0,0 +1,98 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+/**
+ * Enum containing the implemented WebDriver commands.
+ * @link https://www.w3.org/TR/webdriver/#commands
+ */
+enum CommandName: string
+{
+ /**
+ * Create a new session.
+ * @link https://www.w3.org/TR/webdriver/#dfn-new-sessions
+ */
+ case NewSession = 'newSession';
+
+ /**
+ * The the current status of the webdriver server.
+ * @link https://www.w3.org/TR/webdriver/#dfn-status
+ */
+ case Status = 'status';
+
+ /**
+ * Close the window
+ * @link https://www.w3.org/TR/webdriver/#dfn-close-window
+ */
+ case Close = 'close';
+
+ /**
+ * Close the webdriver session.
+ * Implicitly closes the current window.
+ * @link https://www.w3.org/TR/webdriver/#dfn-quit
+ */
+ case Quit = 'quit';
+
+ /**
+ * Execute JavaScript in the context of the currently selected frame or window.
+ * @link https://www.w3.org/TR/webdriver/#dfn-execute-script
+ */
+ case ExecuteScript = 'executeScript';
+
+ /**
+ * Get the source of the current page.
+ * @link https://www.w3.org/TR/webdriver/#dfn-get-page-source
+ */
+ case GetPageSource = 'getPageSource';
+
+ /**
+ * Print the current page.
+ * @link https://www.w3.org/TR/webdriver/#dfn-print-page
+ */
+ case PrintPage = 'printPage';
+
+ /**
+ * Find an element on the page.
+ * @link https://www.w3.org/TR/webdriver/#dfn-find-element
+ */
+ case FindElement = 'findElement';
+
+ /**
+ * Get the path to the endpoint of the command.
+ * @return string
+ */
+ public function getPath(): string
+ {
+ return match ($this) {
+ self::NewSession => '/session',
+ self::Status => '/status',
+ self::Close => '/session/:sessionId/window',
+ self::Quit => '/session/:sessionId',
+ self::ExecuteScript => '/session/:sessionId/execute/sync',
+ self::GetPageSource => '/session/:sessionId/source',
+ self::PrintPage => '/session/:sessionId/print',
+ self::FindElement => '/session/:sessionId/element',
+ };
+ }
+
+ /**
+ * Get the HTTP method of the command.
+ * @return string
+ */
+ public function getMethod(): string
+ {
+ return match ($this) {
+ self::NewSession => 'POST',
+ self::Status => 'GET',
+ self::Close => 'DELETE',
+ self::Quit => 'DELETE',
+ self::ExecuteScript => 'POST',
+ self::GetPageSource => 'GET',
+ self::PrintPage => 'POST',
+ self::FindElement => 'POST',
+ };
+ }
+}
diff --git a/library/Pdfexport/WebDriver/ConditionInterface.php b/library/Pdfexport/WebDriver/ConditionInterface.php
new file mode 100644
index 0000000..233f05c
--- /dev/null
+++ b/library/Pdfexport/WebDriver/ConditionInterface.php
@@ -0,0 +1,20 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+/**
+ * Interface for conditions that can be used for searching elements in the browsers DOM.
+ */
+interface ConditionInterface
+{
+ /**
+ * Apply the condition to the given WebDriver instance and return whether the condition is met.
+ * @param WebDriver $driver the WebDriver instance to apply the condition to
+ *
+ * @return bool
+ */
+ public function apply(WebDriver $driver): bool;
+}
diff --git a/library/Pdfexport/WebDriver/CustomCommand.php b/library/Pdfexport/WebDriver/CustomCommand.php
new file mode 100644
index 0000000..7cbd474
--- /dev/null
+++ b/library/Pdfexport/WebDriver/CustomCommand.php
@@ -0,0 +1,41 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+/**
+ * A custom WebDriver command that allows sending arbitrary requests to the browser.
+ * These commands are not part of the WebDriver protocol and are not covered by the official documentation.
+ */
+class CustomCommand implements CommandInterface
+{
+ /**
+ * Create a new custom command.
+ * @param string $method
+ * @param string $path
+ * @param array $parameters
+ */
+ public function __construct(
+ protected string $method,
+ protected string $path,
+ protected array $parameters = [],
+ ) {
+ }
+
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ public function getParameters(): array
+ {
+ return $this->parameters;
+ }
+}
diff --git a/library/Pdfexport/WebDriver/ElementPresentCondition.php b/library/Pdfexport/WebDriver/ElementPresentCondition.php
new file mode 100644
index 0000000..0c8ffb5
--- /dev/null
+++ b/library/Pdfexport/WebDriver/ElementPresentCondition.php
@@ -0,0 +1,70 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+class ElementPresentCondition implements ConditionInterface
+{
+ protected const WEBDRIVER_ELEMENT_IDENTIFIER = 'element-6066-11e4-a52e-4f735466cecf';
+
+ protected function __construct(
+ protected string $mechanism,
+ protected string $value,
+ ) {
+ }
+
+ public function apply(WebDriver $driver): bool
+ {
+ $response = $driver->execute(
+ Command::findElement($this->mechanism, $this->value),
+ );
+
+ if (isset($response['ELEMENT']) || isset($response[self::WEBDRIVER_ELEMENT_IDENTIFIER])) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public static function byCssSelector(string $selector): static
+ {
+ return new static('css selector', $selector);
+ }
+
+ public static function byLinkText(string $linkText): static
+ {
+ return new static('link text', $linkText);
+ }
+
+ public static function byPartialLinkText(string $partialLinkText): static
+ {
+ return new static('partial link text', $partialLinkText);
+ }
+
+ public static function byId(string $id): static
+ {
+ return static::byCssSelector('#' . $id);
+ }
+
+ public static function byClassName(string $className): static
+ {
+ return static::byCssSelector('.' . $className);
+ }
+
+ public static function byName(string $name): static
+ {
+ return static::byCssSelector('[name="' . $name . '"]');
+ }
+
+ public static function byTagName(string $tagName): static
+ {
+ return new static('tag name', $tagName);
+ }
+
+ public static function byXPath(string $xpath): static
+ {
+ return new static('xpath', $xpath);
+ }
+}
diff --git a/library/Pdfexport/WebDriver/Response.php b/library/Pdfexport/WebDriver/Response.php
new file mode 100644
index 0000000..31ddc29
--- /dev/null
+++ b/library/Pdfexport/WebDriver/Response.php
@@ -0,0 +1,25 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+/**
+ * Represents a WebDriver response.
+ */
+readonly class Response
+{
+ /**
+ * Create a new response.
+ * @param string $sessionId the session ID of the response
+ * @param int $status a http status code for the response
+ * @param mixed|null $value the response value of the response
+ */
+ public function __construct(
+ public string $sessionId,
+ public int $status = 0,
+ public mixed $value = null,
+ ) {
+ }
+}
diff --git a/library/Pdfexport/WebDriver/WebDriver.php b/library/Pdfexport/WebDriver/WebDriver.php
new file mode 100644
index 0000000..e52c08c
--- /dev/null
+++ b/library/Pdfexport/WebDriver/WebDriver.php
@@ -0,0 +1,131 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Module\Pdfexport\WebDriver;
+
+use Exception;
+
+/**
+ * Partial implementation of the WebDriver protocol.
+ * @link https://www.w3.org/TR/webdriver/
+ */
+class WebDriver
+{
+ /**
+ * Create a new WebDriver instance.
+ * @param CommandExecutor|null $executor a command executor instance which is responsible for sending commands to
+ * the webdriver server
+ * @param string|null $sessionId the session if for the connection from the server from the `newSession` command
+ */
+ protected function __construct(
+ protected ?CommandExecutor $executor,
+ protected ?string $sessionId,
+ ) {
+ }
+
+ public function __destruct()
+ {
+ $this->quit();
+ }
+
+ /**
+ * Create a new WebDriver instance with a set of capabilities.
+ *
+ * @param string $url the host and port of the webdriver server
+ * @param Capabilities $capabilities the capabilities to use for the session
+ *
+ * @return static
+ * @throws Exception
+ */
+ public static function create(string $url, Capabilities $capabilities): static
+ {
+ $executor = new CommandExecutor($url);
+
+ $params = [
+ 'capabilities' => [
+ 'firstMatch' => [(object) $capabilities->toW3cCompatibleArray()],
+ ],
+ 'desiredCapabilities' => (object) $capabilities->toArray(),
+ ];
+
+ $cmd = new Command(CommandName::NewSession, $params);
+
+ $response = $executor->execute(null, $cmd);
+
+ return new static($executor, $response->sessionId);
+ }
+
+ /**
+ * Execute a command on the webdriver server.
+ * @param CommandInterface $command the command to execute
+ *
+ * @return mixed the result of the command, the specifics of which depend on the command being executed
+ * @throws Exception
+ */
+ public function execute(CommandInterface $command): mixed
+ {
+ if ($this->sessionId === null) {
+ throw new Exception('Session is not active');
+ }
+
+ $response = $this->executor->execute($this->sessionId, $command);
+
+ return $response->value;
+ }
+
+ /**
+ * Wait synchronously for a condition to be met.
+ * This function uses polling to check the condition.
+ *
+ * @param ConditionInterface $condition the condition to wait for
+ * @param int $timeoutSeconds the maximum time to wait for the condition to be met
+ * @param int $intervalMs the time to wait between checks
+ *
+ * @return mixed
+ * @throws Exception
+ */
+ public function wait(
+ ConditionInterface $condition,
+ int $timeoutSeconds = 10,
+ int $intervalMs = 250
+ ): mixed {
+ $end = microtime(true) + $timeoutSeconds;
+ $lastException = null;
+
+ while ($end > microtime(true)) {
+ try {
+ $result = $condition->apply($this);
+ if ($result !== false) {
+ return $result;
+ }
+ } catch (Exception $e) {
+ $lastException = $e;
+ }
+ usleep($intervalMs * 1000);
+ }
+
+ if ($lastException !== null) {
+ throw $lastException;
+ }
+
+ return false;
+ }
+
+ /**
+ * Cleanly close the webdriver session.
+ *
+ * @return void
+ * @throws Exception
+ */
+ public function quit(): void
+ {
+ if ($this->executor !== null) {
+ $this->execute(new Command(CommandName::Quit));
+ $this->executor = null;
+ }
+
+ $this->sessionId = null;
+ }
+}
diff --git a/module.info b/module.info
index 0467d87..3124a9b 100644
--- a/module.info
+++ b/module.info
@@ -2,4 +2,4 @@ Module: PDF Export
Version: 0.13.0
Requires:
Libraries: icinga-php-library (>=1.0.0), icinga-php-thirdparty (>=1.0.0)
-Description: PDF Export via Google Chrome/Chromium
+Description: PDF Export via Google Chrome/WebDriver
diff --git a/public/js/activate-scripts.js b/public/js/activate-scripts.js
new file mode 100644
index 0000000..0f5ad6c
--- /dev/null
+++ b/public/js/activate-scripts.js
@@ -0,0 +1,32 @@
+// SPDX-FileCopyrightText: 2026 Icinga GmbH
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+function activateScripts(node) {
+ if (isScript(node) === true) {
+ node.parentNode.replaceChild(cloneScript(node) , node);
+ } else {
+ var i = -1, children = node.childNodes;
+ while (++i < children.length) {
+ activateScripts(children[i]);
+ }
+ }
+
+ return node;
+}
+
+function cloneScript(node) {
+ var script = document.createElement("script");
+ script.text = node.innerHTML;
+
+ var i = -1, attrs = node.attributes, attr;
+ while (++i < attrs.length) {
+ script.setAttribute((attr = attrs[i]).name, attr.value);
+ }
+ return script;
+}
+
+function isScript(node) {
+ return node.tagName === 'SCRIPT';
+}
+
+activateScripts(document.documentElement);
diff --git a/public/js/wait-for-layout.js b/public/js/wait-for-layout.js
new file mode 100644
index 0000000..bf9b6e9
--- /dev/null
+++ b/public/js/wait-for-layout.js
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: 2026 Icinga GmbH
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+new Promise((fulfill, reject) => {
+ let timeoutId = setTimeout(() => reject('fail'), 10000);
+
+ if (document.documentElement.dataset.layoutReady === 'yes') {
+ clearTimeout(timeoutId);
+ fulfill(null);
+ return;
+ }
+
+ document.addEventListener('layout-ready', e => {
+ clearTimeout(timeoutId);
+ fulfill(e.detail);
+ }, {
+ once: true
+ });
+})