icingadb-web/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.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

539 lines
19 KiB
PHP

<?php
/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2+ */
namespace Icinga\Module\Icingadb\Web\Control\SearchBar;
use Generator;
use Icinga\Module\Icingadb\Common\Auth;
use Icinga\Module\Icingadb\Common\Database;
use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
use Icinga\Module\Icingadb\Model\CustomvarFlat;
use Icinga\Module\Icingadb\Model\Host;
use Icinga\Module\Icingadb\Model\Service;
use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor;
use ipl\Html\HtmlElement;
use ipl\Orm\Exception\InvalidColumnException;
use ipl\Orm\Exception\InvalidRelationException;
use ipl\Orm\Model;
use ipl\Orm\Query;
use ipl\Orm\Relation;
use ipl\Orm\Relation\BelongsToMany;
use ipl\Orm\Relation\HasOne;
use ipl\Orm\Resolver;
use ipl\Orm\UnionModel;
use ipl\Sql\Expression;
use ipl\Sql\Select;
use ipl\Stdlib\BaseFilter;
use ipl\Stdlib\Filter;
use ipl\Stdlib\Seq;
use ipl\Web\Control\SearchBar\SearchException;
use ipl\Web\Control\SearchBar\Suggestions;
use PDO;
class ObjectSuggestions extends Suggestions
{
use Auth;
use BaseFilter;
use Database;
/** @var Model */
protected $model;
/** @var array */
protected $customVarSources;
/** @var ?array<string, string> */
protected ?array $fixedColumns = null;
public function __construct()
{
$this->customVarSources = [
'checkcommand' => t('Checkcommand %s', '..<customvar-name>'),
'eventcommand' => t('Eventcommand %s', '..<customvar-name>'),
'host' => t('Host %s', '..<customvar-name>'),
'hostgroup' => t('Hostgroup %s', '..<customvar-name>'),
'notification' => t('Notification %s', '..<customvar-name>'),
'notificationcommand' => t('Notificationcommand %s', '..<customvar-name>'),
'service' => t('Service %s', '..<customvar-name>'),
'servicegroup' => t('Servicegroup %s', '..<customvar-name>'),
'timeperiod' => t('Timeperiod %s', '..<customvar-name>'),
'user' => t('Contact %s', '..<customvar-name>'),
'usergroup' => t('Contactgroup %s', '..<customvar-name>')
];
}
/**
* Set the model to show suggestions for
*
* @param string|Model $model
*
* @return $this
*/
public function setModel($model): self
{
if (is_string($model)) {
$model = new $model();
}
$this->model = $model;
return $this;
}
/**
* Get the model to show suggestions for
*
* @return Model
*/
public function getModel(): Model
{
if ($this->model === null) {
throw new \LogicException(
'You are accessing an unset property. Please make sure to set it beforehand.'
);
}
return $this->model;
}
protected function shouldShowRelationFor(string $column): bool
{
if (strpos($column, '.vars.') !== false) {
return false;
}
$tableName = $this->getModel()->getTableName();
$columnPath = explode('.', $column);
switch (count($columnPath)) {
case 3:
if ($columnPath[1] !== 'state' || ! in_array($tableName, ['host', 'service'])) {
return true;
}
// For host/service state relation columns apply the same rules
case 2:
return $columnPath[0] !== $tableName;
default:
return true;
}
}
private function applyBaseFilter(Query $query): void
{
$this->applyRestrictions($query);
if ($this->hasBaseFilter()) {
$query->filter($this->getBaseFilter());
}
}
protected function createQuickSearchFilter($searchTerm)
{
$model = $this->getModel();
$resolver = $model::on($this->getDb())->getResolver();
$quickFilter = Filter::any();
foreach ($model->getSearchColumns() as $column) {
if (strpos($column, '.') === false) {
$column = $resolver->qualifyColumn($column, $model->getTableName());
}
$where = Filter::like($column, $searchTerm);
$where->metaData()->set('columnLabel', $resolver->getColumnDefinition($column)->getLabel());
$quickFilter->add($where);
}
return $quickFilter;
}
protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter)
{
$model = $this->getModel();
$query = $model::on($this->getDb());
$query->limit(static::DEFAULT_LIMIT);
if (strpos($column, ' ') !== false) {
// $column may be a label
list($path, $_) = Seq::find(
$this->fixedColumns ?? self::collectFilterColumns($query->getModel(), $query->getResolver()),
$column,
false
);
if ($path !== null) {
$column = $path;
}
}
$columnPath = $query->getResolver()->qualifyPath($column, $model->getTableName());
list($targetPath, $columnName) = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2);
$isCustomVar = false;
if (substr($targetPath, -5) === '.vars') {
$isCustomVar = true;
$targetPath = substr($targetPath, 0, -4) . 'customvar_flat';
}
if (strpos($targetPath, '.') !== false) {
try {
$query->with($targetPath); // TODO: Remove this, once ipl/orm does it as early
} catch (InvalidRelationException $e) {
throw new SearchException(sprintf(t('"%s" is not a valid relation'), $e->getRelation()));
}
}
if ($isCustomVar) {
$columnPath = $targetPath . '.flatvalue';
$query->filter(Filter::like($targetPath . '.flatname', $columnName));
}
$inputFilter = Filter::like($columnPath, $searchTerm);
$query->columns($columnPath);
$query->orderBy($columnPath);
// This had so many iterations, if it still doesn't work, consider removing it entirely :(
if ($searchFilter instanceof Filter\None) {
$query->filter($inputFilter);
} elseif ($searchFilter instanceof Filter\All) {
$searchFilter->add($inputFilter);
// There may be columns part of $searchFilter which target the base table. These must be
// optimized, otherwise they influence what we'll suggest to the user. (i.e. less)
// The $inputFilter on the other hand must not be optimized, which it wouldn't, but since
// we force optimization on its parent chain, we have to negate that.
$searchFilter->metaData()->set('forceOptimization', true);
$inputFilter->metaData()->set('forceOptimization', false);
} else {
$searchFilter = $inputFilter;
}
$query->filter($searchFilter);
$this->applyBaseFilter($query);
try {
return (new ObjectSuggestionsCursor($query->getDb(), $query->assembleSelect()->distinct()))
->setFetchMode(PDO::FETCH_COLUMN);
} catch (InvalidColumnException $e) {
throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn()));
}
}
protected function fetchColumnSuggestions($searchTerm)
{
$model = $this->getModel();
$query = $model::on($this->getDb());
$parsedArrayVars = [];
$exactSearchTerm = trim($searchTerm, ' *');
$exactVarSearches = [];
$titleAdded = false;
// Suggest exact custom variable matches first
if ($exactSearchTerm !== '') {
foreach (
$this->getDb()->select($this->queryCustomvarConfig(
Filter::any(
Filter::equal('flatname', $exactSearchTerm),
Filter::like('flatname', $exactSearchTerm . '[*]') // Filter for array type custom variables
)
)) as $customVar
) {
$search = $name = $customVar->flatname;
$exactVarSearches[] = $search;
if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
$name = substr($search, 0, -(strlen($matches[1]) + 2));
if (isset($parsedArrayVars[$name])) {
continue;
}
$parsedArrayVars[$name] = true;
$search = $name . '[*]';
}
foreach ($this->customVarSources as $relation => $label) {
if (isset($customVar->$relation)) {
if ($titleAdded === false) {
$this->addHtml(HtmlElement::create(
'li',
['class' => static::SUGGESTION_TITLE_CLASS],
t('Best Suggestions')
));
$titleAdded = true;
}
yield $relation . '.vars.' . $search => sprintf($label, $name);
}
}
}
}
// Ordinary columns comes after exact matches,
// or if there ar no exact matches they come first
$titleAdded = false;
$columns = $this->fixedColumns ?? self::collectFilterColumns($query->getModel(), $query->getResolver());
foreach ($columns as $columnName => $columnMeta) {
if ($this->matchSuggestion($columnName, $columnMeta, $searchTerm)) {
if ($titleAdded === false) {
$this->addHtml(HtmlElement::create(
'li',
['class' => static::SUGGESTION_TITLE_CLASS],
t('Columns')
));
$titleAdded = true;
}
yield $columnName => $columnMeta;
}
}
// Finally, the other custom variable suggestions
$titleAdded = false;
if (! empty($exactVarSearches)) {
$varFilter = Filter::all(
Filter::like('flatname', $searchTerm),
Filter::unequal('flatname', $exactVarSearches)
);
} else {
$varFilter = Filter::like('flatname', $searchTerm);
}
foreach ($this->getDb()->select($this->queryCustomvarConfig($varFilter)) as $customVar) {
$search = $name = $customVar->flatname;
if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
$name = substr($search, 0, -(strlen($matches[1]) + 2));
if (isset($parsedArrayVars[$name])) {
continue;
}
$parsedArrayVars[$name] = true;
$search = $name . '[*]';
}
foreach ($this->customVarSources as $relation => $label) {
if (isset($customVar->$relation)) {
// Suggest exact custom variable matches first
if ($titleAdded === false) {
$this->addHtml(HtmlElement::create(
'li',
['class' => static::SUGGESTION_TITLE_CLASS],
t('Custom Variables')
));
$titleAdded = true;
}
yield $relation . '.vars.' . $search => sprintf($label, $name);
}
}
}
}
protected function matchSuggestion($path, $label, $searchTerm)
{
if (preg_match('/[_.](id|bin|checksum)$/', $path)) {
// Only suggest exotic columns if the user knows about them
$trimmedSearch = trim($searchTerm, ' *');
return substr($path, -strlen($trimmedSearch)) === $trimmedSearch;
}
return parent::matchSuggestion($path, $label, $searchTerm);
}
/**
* Create a query to fetch all available custom variables matching the given filter
*
* @param Filter\Rule $filter
*
* @return Select
*/
protected function queryCustomvarConfig(Filter\Rule $filter): Select
{
$customVars = CustomvarFlat::on($this->getDb());
$tableName = $customVars->getModel()->getTableName();
$resolver = $customVars->getResolver();
$scalarQueries = [];
$aggregates = ['flatname'];
foreach ($resolver->getRelations($customVars->getModel()) as $name => $relation) {
if (isset($this->customVarSources[$name]) && $relation instanceof BelongsToMany) {
$query = $customVars->createSubQuery(
$relation->getTarget(),
$resolver->qualifyPath($name, $tableName)
);
$this->applyBaseFilter($query);
$aggregates[$name] = new Expression("MAX($name)");
$scalarQueries[$name] = $query->assembleSelect()
->resetColumns()->columns(new Expression('1'))
->limit(1);
}
}
$customVars->columns('flatname');
$this->applyRestrictions($customVars);
$customVars->filter($filter);
// applyRestrictions() does not hide protected vars, but since querying them is not possible anymore,
// we have to. Otherwise, the user can choose a protected var and get an error.
$protectedVarFilter = Filter::any();
foreach ($this->getAuth()->getRestrictions('icingadb/protect/variables') as $restriction) {
$protectedVarFilter->add($this->parseDenylist($restriction, 'flatname'));
}
$customVars->filter($protectedVarFilter);
$idColumn = $resolver->qualifyColumn('id', $resolver->getAlias($customVars->getModel()));
$customVars = $customVars->assembleSelect();
$customVars->columns($scalarQueries);
$customVars->groupBy($idColumn);
$customVars->limit(static::DEFAULT_LIMIT);
// This outer query exists only because there's no way to combine aggregates and sub queries (yet)
return (new Select())->columns($aggregates)->from(['results' => $customVars])->groupBy('flatname');
}
protected function filterColumnSuggestions($data, $searchTerm)
{
// Remove filtering here, as fetchColumnSuggestions already performs it
yield from $data;
}
/**
* Collect all columns of this model and its relations that can be used for filtering
*
* @param Model $model
* @param Resolver $resolver
*
* @return Generator
*/
public static function collectFilterColumns(Model $model, Resolver $resolver): Generator
{
if ($model instanceof UnionModel) {
$models = [];
foreach ($model->getUnions() as $union) {
/** @var Model $unionModel */
$unionModel = new $union[0]();
$models[$unionModel->getTableName()] = $unionModel;
self::collectRelations($resolver, $unionModel, $models, []);
}
} else {
$models = [$model->getTableName() => $model];
self::collectRelations($resolver, $model, $models, []);
}
/** @var Model $targetModel */
foreach ($models as $path => $targetModel) {
foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
yield $path . '.' . $columnName => $definition->getLabel();
}
}
foreach ($resolver->getBehaviors($model) as $behavior) {
if ($behavior instanceof ReRoute) {
foreach ($behavior->getRoutes() as $name => $route) {
$relation = $resolver->resolveRelation(
$resolver->qualifyPath($route, $model->getTableName()),
$model
);
foreach ($resolver->getColumnDefinitions($relation->getTarget()) as $columnName => $definition) {
yield $name . '.' . $columnName => $definition->getLabel();
}
}
}
}
if ($model instanceof UnionModel) {
$queries = $model->getUnions();
$baseModelClass = end($queries)[0];
$model = new $baseModelClass();
}
$foreignMetaDataSources = [];
if (! $model instanceof Host) {
$foreignMetaDataSources[] = 'host.user';
$foreignMetaDataSources[] = 'host.usergroup';
}
if (! $model instanceof Service) {
$foreignMetaDataSources[] = 'service.user';
$foreignMetaDataSources[] = 'service.usergroup';
}
foreach ($foreignMetaDataSources as $path) {
$foreignColumnDefinitions = $resolver->getColumnDefinitions($resolver->resolveRelation(
$resolver->qualifyPath($path, $model->getTableName()),
$model
)->getTarget());
foreach ($foreignColumnDefinitions as $columnName => $columnDefinition) {
yield "$path.$columnName" => $columnDefinition->getLabel();
}
}
}
/**
* Collect all direct relations of the given model
*
* A direct relation is either a direct descendant of the model
* or a descendant of such related in a to-one cardinality.
*
* @param Resolver $resolver
* @param Model $subject
* @param array $models
* @param array $path
*/
protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
{
foreach ($resolver->getRelations($subject) as $name => $relation) {
/** @var Relation $relation */
if (
empty($path) || (
($name === 'state' && $path[count($path) - 1] !== 'last_comment')
|| $name === 'last_comment'
|| $name === 'notificationcommand' && $path[0] === 'notification'
)
) {
$relationPath = [$name];
if ($relation instanceof HasOne && empty($path)) {
array_unshift($relationPath, $subject->getTableName());
}
$relationPath = array_merge($path, $relationPath);
$models[join('.', $relationPath)] = $relation->getTarget();
self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
}
}
}
/**
* Reduce {@see $customVarSources} to only given relations to fetch variables from
*
* @param string[] $relations
*
* @return $this
*/
public function onlyWithCustomVarSources(array $relations): self
{
$this->customVarSources = array_intersect_key($this->customVarSources, array_flip($relations));
return $this;
}
/**
* Provide suggestions based on a fixed set of columns
*
* @param array<string, string> $columns
*
* @return $this
*/
public function withFixedColumns(array $columns): static
{
$this->fixedColumns = $columns;
return $this;
}
}