summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Web/Controller.php
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icingadb/Web/Controller.php')
-rw-r--r--library/Icingadb/Web/Controller.php542
1 files changed, 542 insertions, 0 deletions
diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php
new file mode 100644
index 0000000..ad9f07e
--- /dev/null
+++ b/library/Icingadb/Web/Controller.php
@@ -0,0 +1,542 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web;
+
+use Exception;
+use Generator;
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Version;
+use Icinga\Application\Web;
+use Icinga\Data\ConfigObject;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\Http\HttpBadRequestException;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\SearchControls;
+use Icinga\Module\Icingadb\Data\CsvResultSet;
+use Icinga\Module\Icingadb\Data\JsonResultSet;
+use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport;
+use Icinga\Security\SecurityException;
+use Icinga\User\Preferences;
+use Icinga\User\Preferences\PreferencesStore;
+use Icinga\Util\Environment;
+use Icinga\Util\Json;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+use ipl\Orm\UnionQuery;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\PaginationControl;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class Controller extends CompatController
+{
+ use Auth;
+ use Database;
+ use SearchControls;
+
+ /** @var Filter\Rule Filter from query string parameters */
+ private $filter;
+
+ /** @var string|null */
+ private $format;
+
+ /** @var bool */
+ private $formatProcessed = false;
+
+ /**
+ * Get the filter created from query string parameters
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter(): Filter\Rule
+ {
+ if ($this->filter === null) {
+ $this->filter = QueryString::parse((string) $this->params);
+ }
+
+ return $this->filter;
+ }
+
+ /**
+ * Create column control
+ *
+ * @param Query $query
+ * @param ViewModeSwitcher $viewModeSwitcher
+ *
+ * @return array provided columns
+ *
+ * @throws HttpBadRequestException
+ */
+ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwitcher): array
+ {
+ // All of that is essentially what `ColumnControl::apply()` should do
+ $viewMode = $this->getRequest()->getUrl()->getParam($viewModeSwitcher->getViewModeParam());
+ $columnsDef = $this->params->shift('columns');
+ if (! $columnsDef) {
+ if ($viewMode === 'tabular') {
+ $this->httpBadRequest('Missing parameter "columns"');
+ }
+
+ return [];
+ }
+
+ $columns = [];
+ foreach (explode(',', $columnsDef) as $column) {
+ if ($column = trim($column)) {
+ $columns[] = $column;
+ }
+ }
+
+ $query->withColumns($columns);
+
+ if (! $viewMode) {
+ $viewModeSwitcher->setViewMode('tabular');
+ }
+
+ // For now this also returns the columns, but they should be accessible
+ // by calling `ColumnControl::getColumns()` in the future
+ return $columns;
+ }
+
+ /**
+ * Create and return the ViewModeSwitcher
+ *
+ * This automatically shifts the view mode URL parameter from {@link $params}.
+ *
+ * @param PaginationControl $paginationControl
+ * @param LimitControl $limitControl
+ * @param bool $verticalPagination
+ *
+ * @return ViewModeSwitcher|GridViewModeSwitcher
+ */
+ public function createViewModeSwitcher(
+ PaginationControl $paginationControl,
+ LimitControl $limitControl,
+ bool $verticalPagination = false
+ ): ViewModeSwitcher {
+ $controllerName = $this->getRequest()->getControllerName();
+
+ // TODO: Make this configurable somehow. The route shouldn't be checked to choose the view modes!
+ if ($controllerName === 'hostgroups' || $controllerName === 'servicegroups') {
+ $viewModeSwitcher = new GridViewModeSwitcher();
+ } else {
+ $viewModeSwitcher = new ViewModeSwitcher();
+ }
+
+ $viewModeSwitcher->setIdProtector([$this->getRequest(), 'protectId']);
+
+ $user = $this->Auth()->getUser();
+ if (($preferredModes = $user->getAdditional('icingadb.view_modes')) === null) {
+ try {
+ $preferredModes = Json::decode(
+ $user->getPreferences()->getValue('icingadb', 'view_modes', '[]'),
+ true
+ );
+ } catch (JsonDecodeException $e) {
+ Logger::error('Failed to load preferred view modes for user "%s": %s', $user->getUsername(), $e);
+ $preferredModes = [];
+ }
+
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+ }
+
+ $requestRoute = $this->getRequest()->getUrl()->getPath();
+ if (isset($preferredModes[$requestRoute])) {
+ $viewModeSwitcher->setDefaultViewMode($preferredModes[$requestRoute]);
+ }
+
+ $viewModeSwitcher->populate([
+ $viewModeSwitcher->getViewModeParam() => $this->params->shift($viewModeSwitcher->getViewModeParam())
+ ]);
+
+ $session = $this->Window()->getSessionNamespace(
+ 'icingadb-viewmode-' . $this->Window()->getContainerId()
+ );
+
+ $viewModeSwitcher->on(
+ ViewModeSwitcher::ON_SUCCESS,
+ function (ViewModeSwitcher $viewModeSwitcher) use (
+ $user,
+ $preferredModes,
+ $paginationControl,
+ $verticalPagination,
+ &$session
+ ) {
+ $viewMode = $viewModeSwitcher->getValue($viewModeSwitcher->getViewModeParam());
+ $requestUrl = Url::fromRequest();
+
+ $preferredModes[$requestUrl->getPath()] = $viewMode;
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+
+ try {
+ $preferencesStore = PreferencesStore::create(new ConfigObject([
+ //TODO: Don't set store key as it will no longer be needed once we drop support for
+ // lower version of icingaweb2 then v2.11.
+ //https://github.com/Icinga/icingaweb2/pull/4765
+ 'store' => Config::app()->get('global', 'config_backend', 'db'),
+ 'resource' => Config::app()->get('global', 'config_resource')
+ ]), $user);
+ $preferencesStore->load();
+ $preferencesStore->save(
+ new Preferences(['icingadb' => ['view_modes' => Json::encode($preferredModes)]])
+ );
+ } catch (Exception $e) {
+ Logger::error('Failed to save preferred view mode for user "%s": %s', $user->getUsername(), $e);
+ }
+
+ $pageParam = $paginationControl->getPageParam();
+ $limitParam = LimitControl::DEFAULT_LIMIT_PARAM;
+ $currentPage = $paginationControl->getCurrentPageNumber();
+
+ $requestUrl->setParam($viewModeSwitcher->getViewModeParam(), $viewMode);
+ if (! $requestUrl->hasParam($limitParam)) {
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $session->set('previous_page', $currentPage);
+ $session->set('request_path', $requestUrl->getPath());
+
+ $limit = $paginationControl->getLimit();
+ if (! $verticalPagination) {
+ // We are computing it based on the first element being rendered on this current page
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit * 2)) + 1);
+ } else {
+ $currentPage = (int) (round($currentPage * $limit / ($limit * 2)));
+ }
+
+ $session->set('current_page', $currentPage);
+ } elseif (
+ $viewModeSwitcher->getDefaultViewMode() === 'minimal'
+ || $viewModeSwitcher->getDefaultViewMode() === 'grid'
+ ) {
+ $limit = $paginationControl->getLimit();
+ if ($currentPage === $session->get('current_page')) {
+ // No other page numbers have been selected, i.e the user only
+ // switches back and forth without changing the page numbers
+ $currentPage = $session->get('previous_page');
+ } elseif (! $verticalPagination) {
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit / 2)) + 1);
+ } else {
+ $currentPage = (int) (floor($currentPage * $limit / ($limit / 2)));
+ }
+
+ $session->clear();
+ }
+
+ if (($requestUrl->hasParam($pageParam) && $currentPage > 1) || $currentPage > 1) {
+ $requestUrl->setParam($pageParam, $currentPage);
+ } else {
+ $requestUrl->remove($pageParam);
+ }
+ }
+
+ $this->redirectNow($requestUrl);
+ }
+ )->handleRequest(ServerRequest::fromGlobals());
+
+ $viewMode = $viewModeSwitcher->getViewMode();
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $hasLimitParam = Url::fromRequest()->hasParam($limitControl->getLimitParam());
+
+ if ($paginationControl->getDefaultPageSize() <= LimitControl::DEFAULT_LIMIT && ! $hasLimitParam) {
+ $paginationControl->setDefaultPageSize($paginationControl->getDefaultPageSize() * 2);
+ $limitControl->setDefaultLimit($limitControl->getDefaultLimit() * 2);
+
+ $paginationControl->apply();
+ }
+ }
+
+ $requestPath = $session->get('request_path');
+ if ($requestPath && $requestPath !== $requestRoute) {
+ $session->clear();
+ }
+
+ return $viewModeSwitcher;
+ }
+
+ /**
+ * Process a search request
+ *
+ * @param Query $query
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ public function handleSearchRequest(Query $query, array $additionalColumns = [])
+ {
+ $q = trim($this->params->shift('q', ''), ' *');
+ if (! $q) {
+ return;
+ }
+
+ $filter = Filter::any();
+ $this->prepareSearchFilter($query, $q, $filter, $additionalColumns);
+
+ $redirectUrl = Url::fromRequest();
+ $redirectUrl->setParams($this->params)->setFilter($filter);
+
+ $this->getResponse()->redirectAndExit($redirectUrl);
+ }
+
+ /**
+ * Prepare the given search filter
+ *
+ * @param Query $query
+ * @param string $search
+ * @param Filter\Any $filter
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter, array $additionalColumns)
+ {
+ $columns = array_merge($query->getModel()->getSearchColumns(), $additionalColumns);
+ foreach ($columns as $column) {
+ $filter->add(Filter::like(
+ $query->getResolver()->qualifyColumn($column, $query->getModel()->getTableName()),
+ "*$search*"
+ ));
+ }
+ }
+
+ /**
+ * Require permission to access the given route
+ *
+ * @param string $name If NULL, the current controller name is used
+ *
+ * @throws SecurityException
+ */
+ public function assertRouteAccess(string $name = null)
+ {
+ if (! $name) {
+ $name = $this->getRequest()->getControllerName();
+ }
+
+ if (! $this->isPermittedRoute($name)) {
+ throw new SecurityException('No permission to access this route');
+ }
+ }
+
+ public function export(Query ...$queries)
+ {
+ if ($this->format === 'sql') {
+ foreach ($queries as $query) {
+ list($sql, $values) = $query->getDb()->getQueryBuilder()->assembleSelect($query->assembleSelect());
+
+ $unused = [];
+ foreach ($values as $value) {
+ $pos = strpos($sql, '?');
+ if ($pos !== false) {
+ if (is_string($value)) {
+ $value = "'" . $value . "'";
+ }
+
+ $sql = substr_replace($sql, $value, $pos, 1);
+ } else {
+ $unused[] = $value;
+ }
+ }
+
+ if (!empty($unused)) {
+ $sql .= ' /* Unused values: "' . join('", "', $unused) . '" */';
+ }
+
+ $this->content->add(Html::tag('pre', $sql));
+ }
+
+ return true;
+ }
+
+ // It only makes sense to export a single result to CSV or JSON
+ $query = $queries[0];
+
+ // No matter the format, a limit should only apply if set
+ if ($this->format !== null) {
+ $query->limit(Url::fromRequest()->getParam('limit'));
+ }
+
+ if ($this->format === 'json' || $this->format === 'csv') {
+ $response = $this->getResponse();
+ $fileName = $this->view->title;
+
+ ob_end_clean();
+ Environment::raiseExecutionTime();
+
+ if ($this->format === 'json') {
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.json'
+ )
+ ->sendResponse();
+
+ JsonResultSet::stream($query);
+ } else {
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.csv'
+ )
+ ->sendResponse();
+
+ CsvResultSet::stream($query);
+ }
+ }
+
+ $this->getTabs()->enableDataExports();
+ }
+
+ /**
+ * @todo Remove once support for Icinga Web 2 v2.9.x is dropped
+ */
+ protected function sendAsPdf()
+ {
+ if (! Icinga::app()->getModuleManager()->has('pdfexport')) {
+ throw new ConfigurationError('The pdfexport module is required for exports to PDF');
+ }
+
+ if (version_compare(Version::VERSION, '2.10.0', '>=')) {
+ parent::sendAsPdf();
+ return;
+ }
+
+ putenv('ICINGAWEB_EXPORT_FORMAT=pdf');
+ Environment::raiseMemoryLimit('512M');
+ Environment::raiseExecutionTime(300);
+
+ $time = DateFormatter::formatDateTime(time());
+
+ $doc = (new PrintableHtmlDocument())
+ ->setTitle($this->view->title ?? '')
+ ->setHeader(Html::wantHtml([
+ Html::tag('span', ['class' => 'title']),
+ Html::tag('time', null, $time)
+ ]))
+ ->setFooter(Html::wantHtml([
+ Html::tag('span', null, [
+ t('Page') . ' ',
+ Html::tag('span', ['class' => 'pageNumber']),
+ ' / ',
+ Html::tag('span', ['class' => 'totalPages'])
+ ]),
+ Html::tag('p', null, Url::fromRequest()->setParams($this->params))
+ ]))
+ ->addHtml($this->content);
+ $doc->getAttributes()->add('class', 'icinga-module module-icingadb');
+
+ Pdfexport::first()->streamPdfFromHtml($doc, sprintf(
+ '%s-%s',
+ $this->view->title ?: $this->getRequest()->getActionName(),
+ $time
+ ));
+ }
+
+ public function dispatch($action)
+ {
+ // Notify helpers of action preDispatch state
+ $this->_helper->notifyPreDispatch();
+
+ $this->preDispatch();
+
+ if ($this->getRequest()->isDispatched()) {
+ // If pre-dispatch hooks introduced a redirect then stop dispatch
+ // @see ZF-7496
+ if (! $this->getResponse()->isRedirect()) {
+ $interceptable = $this->$action();
+ if ($interceptable instanceof Generator) {
+ foreach ($interceptable as $stopSignal) {
+ if ($stopSignal === true) {
+ $this->formatProcessed = true;
+ break;
+ }
+ }
+ }
+ }
+ $this->postDispatch();
+ }
+
+ // whats actually important here is that this action controller is
+ // shutting down, regardless of dispatching; notify the helpers of this
+ // state
+ $this->_helper->notifyPostDispatch();
+ }
+
+ protected function addContent(ValidHtml $content)
+ {
+ if ($content instanceof BaseItemList || $content instanceof BaseItemTable) {
+ $this->content->getAttributes()->add('class', 'full-width');
+ } elseif ($content instanceof StateItemTable) {
+ $this->content->getAttributes()->add('class', 'full-height');
+ }
+
+ return parent::addContent($content);
+ }
+
+ public function filter(Query $query, Filter\Rule $filter = null): self
+ {
+ if ($this->format !== 'sql' || $this->hasPermission('config/authentication/roles/show')) {
+ $this->applyRestrictions($query);
+ }
+
+ if ($query instanceof UnionQuery) {
+ foreach ($query->getUnions() as $query) {
+ $query->filter($filter ?: $this->getFilter());
+ }
+ } else {
+ $query->filter($filter ?: $this->getFilter());
+ }
+
+ return $this;
+ }
+
+ public function preDispatch()
+ {
+ parent::preDispatch();
+
+ $this->format = $this->params->shift('format');
+ }
+
+ public function postDispatch()
+ {
+ if (! $this->formatProcessed && $this->format !== null && $this->format !== 'pdf') {
+ // The purpose of this is not only to show that a requested format isn't supported.
+ // It's main purpose is to not allow to bypass restrictions with `?format=sql` as
+ // it may be possible that an action applies restrictions, but doesn't support any
+ // output formats. Since the restrictions are bypassed in method `$this->filter()`
+ // for the SQL output format and the actual format processing is part of a different
+ // method (`$this->export()`) which needs to be called explicitly by an action,
+ // it's otherwise possible for bad individuals to access unrestricted data.
+ $this->httpBadRequest(t('This route does not support the requested output format'));
+ }
+
+ parent::postDispatch();
+ }
+
+ protected function moduleInit()
+ {
+ /** @var Web $app */
+ $app = Icinga::app();
+ $app->getFrontController()
+ ->getPlugin('Zend_Controller_Plugin_ErrorHandler')
+ ->setErrorHandlerModule('icingadb');
+ }
+}