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 @@ -
- -
-
- -
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 + }); +})