icingadb-web/library/Icingadb/Web/Controller.php
Alexander A. Klimov 3c8ed68cc6 Upgrade license from GPLv2 to GPLv2+
This was easy because only README.md and doc/01-About.md were redacted manually, everything else via:
git ls-files -z |xargs -0 perl -pi -e 's/Icinga GmbH \| GPLv2/Icinga GmbH | GPLv2+/'

This is legal because we have only merged PRs with label:cla/signed or made by Icinga staff:
https://github.com/Icinga/icingadb-web/pulls?page=1&q=is%3Apr+is%3Aclosed+-label%3Acla%2Fsigned+-author%3Anilmerg

This has no risk for us in people distributing their own version under GPLv3 only.
After all, we won't take their patches anyway, unless they sign our CLA.

This is the cleanest solution for having e.g. these in one address space:

* Icinga Web, GPLv2+
* K8s Web, AGPLv3
* Thirdparty, some LGPLv3 and Apache-2.0

Apropos, K8s Web is even v3-licensed on purpose, to have a stronger protection against cloud ops.
2025-11-21 13:31:24 +01:00

503 lines
18 KiB
PHP

<?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\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;
}
}
// When exporting as CSV or JSON, and the user requested specific columns, only those should be included
if ($this->format === 'csv' || $this->format === 'json') {
$query->columns($columns);
} else {
$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([
'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) {
if (strpos($column, '.') === false) {
$column = $query->getResolver()->qualifyColumn($column, $query->getModel()->getTableName());
}
$filter->add(Filter::like($column, "*$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) {
if (! Url::fromRequest()->hasParam('limit')) {
$query->limit(null)
->offset(null);
}
}
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();
}
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 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',
$this->getRequest()->isApiRequest()
? 'json'
: null
);
}
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');
}
}