diff --git a/application/controllers/SourceController.php b/application/controllers/SourceController.php new file mode 100644 index 000000000..2b3b4e408 --- /dev/null +++ b/application/controllers/SourceController.php @@ -0,0 +1,62 @@ +assertPermission('config/modules'); + } + + public function indexAction(): void + { + /** @var int $sourceId */ + $sourceId = $this->params->getRequired('id'); + + /** @var ?Source $source */ + $source = Source::on(Database::get()) + ->filter(Filter::equal('id', $sourceId)) + ->first(); + if ($source === null) { + $this->httpNotFound($this->translate('Source not found')); + } + + $form = (new SourceForm(Database::get(), $sourceId)) + ->populate($source) + ->on(SourceForm::ON_SUCCESS, function (SourceForm $form) { + /** @var string $sourceName */ + $sourceName = $form->getValue('name'); + + /** @var FormSubmitElement $pressedButton */ + $pressedButton = $form->getPressedSubmitElement(); + if ($pressedButton->getName() === 'delete') { + Notification::success(sprintf( + $this->translate('Deleted source "%s" successfully'), + $sourceName + )); + } else { + Notification::success(sprintf( + $this->translate('Updated source "%s" successfully'), + $sourceName + )); + } + + $this->switchToSingleColumnLayout(); + })->handleRequest($this->getServerRequest()); + + $this->addTitleTab(sprintf($this->translate('Source: %s'), $source->name)); + $this->addContent($form); + } +} diff --git a/application/controllers/SourcesController.php b/application/controllers/SourcesController.php new file mode 100644 index 000000000..b8230fff4 --- /dev/null +++ b/application/controllers/SourcesController.php @@ -0,0 +1,164 @@ +assertPermission('config/modules'); + } + + public function indexAction(): void + { + $sources = Source::on(Database::get()) + ->columns(['id', 'type', 'name']); + + $limitControl = $this->createLimitControl(); + $paginationControl = $this->createPaginationControl($sources); + $sortControl = $this->createSortControl( + $sources, + [ + 'name' => t('Name'), + 'type' => t('Type') + ] + ); + + $searchBar = $this->createSearchBar( + $sources, + [ + $limitControl->getLimitParam(), + $sortControl->getSortParam() + ] + ); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = QueryString::parse((string) $this->params); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + return; + } + } else { + $filter = $searchBar->getFilter(); + } + + $sources->filter($filter); + + $this->addControl($paginationControl); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($searchBar); + $this->addContent( + (new ButtonLink( + t('Add Source'), + Url::fromPath('notifications/sources/add'), + 'plus' + ))->setBaseTarget('_next') + ->addAttributes(['class' => 'add-new-component']) + ); + + $this->mergeTabs($this->Module()->getConfigTabs()); + $this->getTabs()->activate('sources'); + $this->addContent(new SourceList($sources->execute())); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); + } + } + + public function addAction(): void + { + $form = (new SourceForm(Database::get())) + ->on(SourceForm::ON_SUCCESS, function (SourceForm $form) { + /** @var string $sourceName */ + $sourceName = $form->getValue('name'); + Notification::success(sprintf(t('Added new source %s has successfully'), $sourceName)); + $this->switchToSingleColumnLayout(); + }) + ->handleRequest($this->getServerRequest()); + + $this->addTitleTab($this->translate('Add Source')); + $this->addContent($form); + } + + public function completeAction(): void + { + $suggestions = new ObjectSuggestions(); + $suggestions->setModel(Source::class); + $suggestions->forRequest($this->getServerRequest()); + $this->getDocument()->add($suggestions); + } + + public function searchEditorAction(): void + { + $editor = $this->createSearchEditor( + Source::on(Database::get()), + [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM, + ] + ); + + $this->setTitle($this->translate('Adjust Filter')); + $this->getDocument()->add($editor); + } + + /** + * Add attribute 'class' => 'full-width' if the content is an instance of BaseItemList + * + * @param ValidHtml $content + * + * @return $this + */ + protected function addContent(ValidHtml $content) + { + if ($content instanceof BaseItemList) { + $this->content->getAttributes()->add('class', 'full-width'); + } + + return parent::addContent($content); + } + + /** + * Merge tabs with other tabs contained in this tab panel + * + * @param Tabs $tabs + * + * @return void + */ + protected function mergeTabs(Tabs $tabs): void + { + /** @var Tab $tab */ + foreach ($tabs->getTabs() as $tab) { + /** @var string $name */ + $name = $tab->getName(); + if ($name) { + $this->tabs->add($name, $tab); + } + } + } +} diff --git a/application/forms/SourceForm.php b/application/forms/SourceForm.php new file mode 100644 index 000000000..d26d2f6db --- /dev/null +++ b/application/forms/SourceForm.php @@ -0,0 +1,311 @@ +db = $db; + $this->sourceId = $sourceId; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + + $this->addElement( + 'text', + 'name', + [ + 'label' => $this->translate('Name'), + 'required' => true + ] + ); + $this->addElement( + 'select', + 'icinga_or_other', + [ + 'label' => $this->translate('Type'), + 'class' => 'autosubmit', + 'ignore' => true, + 'required' => true, + 'value' => 'icinga', + 'options' => [ + 'icinga' => 'Icinga', + 'other' => $this->translate('Other') + ] + ] + ); + + $chosenType = $this->getPopulatedValue('icinga_or_other'); + $configuredType = $this->getPopulatedValue('type'); + $showIcingaApiConfig = $chosenType === 'icinga' + || ($chosenType === null && $configuredType === null) + || ($chosenType === null && $configuredType === Source::ICINGA_TYPE_NAME); + + if ($showIcingaApiConfig) { + // TODO: Shouldn't be necessary: https://github.com/Icinga/ipl-html/issues/131 + $this->clearPopulatedValue('type'); + + $this->addElement( + 'hidden', + 'type', + [ + 'required' => true, + 'disabled' => true, + 'value' => Source::ICINGA_TYPE_NAME + ] + ); + $this->addElement( + 'text', + 'icinga2_base_url', + [ + 'required' => true, + 'label' => $this->translate('API URL') + ] + ); + $this->addElement( + 'text', + 'icinga2_auth_user', + [ + 'required' => true, + 'label' => $this->translate('API Username'), + 'autocomplete' => 'off' + ] + ); + $this->addElement( + 'password', + 'icinga2_auth_pass', + [ + 'required' => true, + 'label' => $this->translate('API Password'), + 'autocomplete' => 'new-password' + ] + ); + $this->addElement( + 'checkbox', + 'icinga2_insecure_tls', + [ + 'class' => 'autosubmit', + 'label' => $this->translate('Verify API Certificate'), + 'checkedValue' => 'n', + 'uncheckedValue' => 'y', + 'value' => true + ] + ); + + /** @var CheckboxElement $insecureBox */ + $insecureBox = $this->getElement('icinga2_insecure_tls'); + if ($insecureBox->isChecked()) { + $this->addElement( + 'text', + 'icinga2_common_name', + [ + 'label' => $this->translate('Common Name'), + 'description' => $this->translate( + 'The CN of the API certificate. Only required if it differs from the host name' + ) + ] + ); + + $this->addElement( + 'textarea', + 'icinga2_ca_pem', + [ + 'cols' => 65, + 'rows' => 28, + 'required' => true, + 'validators' => [new X509CertValidator()], + 'label' => $this->translate('CA Certificate'), + 'description' => $this->translate('The certificate of the Icinga CA') + ] + ); + } + } else { + $this->getElement('icinga_or_other')->setValue('other'); + + $this->addElement( + 'text', + 'type', + [ + 'required' => true, + 'label' => $this->translate('Type Name'), + 'validators' => [new CallbackValidator(function (string $value, CallbackValidator $validator) { + if ($value === Source::ICINGA_TYPE_NAME) { + $validator->addMessage($this->translate('This name is reserved and cannot be used')); + + return false; + } + + return true; + })] + ] + ); + $this->addElement( + 'password', + 'listener_password', + [ + 'ignore' => true, + 'required' => $this->sourceId === null, + 'label' => $this->sourceId !== null + ? $this->translate('New Password') + : $this->translate('Password'), + 'autocomplete' => 'new-password', + 'validators' => [['name' => 'StringLength', 'options' => ['min' => 16]]] + ] + ); + $this->addElement( + 'password', + 'listener_password_dupe', + [ + 'ignore' => true, + 'required' => $this->sourceId === null, + 'label' => $this->translate('Repeat Password'), + 'autocomplete' => 'new-password', + 'validators' => [new CallbackValidator(function (string $value, CallbackValidator $validator) { + if ($value !== $this->getValue('listener_password')) { + $validator->addMessage($this->translate('Passwords do not match')); + + return false; + } + + return true; + })] + ] + ); + + // Preserves (some) entered data even if the user switches between types + $this->addElement('hidden', 'icinga2_base_url'); + $this->addElement('hidden', 'icinga2_auth_user'); + $this->addElement('hidden', 'icinga2_insecure_tls'); + $this->addElement('hidden', 'icinga2_common_name'); + $this->addElement('hidden', 'icinga2_ca_pem'); + } + + $this->addElement( + 'submit', + 'save', + [ + 'label' => $this->sourceId === null ? + $this->translate('Add Source') : + $this->translate('Save Changes') + ] + ); + + if ($this->sourceId !== null) { + /** @var FormSubmitElement $deleteButton */ + $deleteButton = $this->createElement( + 'submit', + 'delete', + [ + 'label' => $this->translate('Delete'), + 'class' => 'btn-remove', + 'formnovalidate' => true + ] + ); + + $this->registerElement($deleteButton); + + /** @var BaseHtmlElement $submitWrapper */ + $submitWrapper = $this->getElement('save')->getWrapper(); + $submitWrapper->prepend($deleteButton); + } + } + + public function isValid() + { + $pressedButton = $this->getPressedSubmitElement(); + if ($pressedButton && $pressedButton->getName() === 'delete') { + $csrfElement = $this->getElement('CSRFToken'); + + if (! $csrfElement->isValid()) { + return false; + } + + return true; + } + + return parent::isValid(); + } + + public function hasBeenSubmitted() + { + if ($this->getPressedSubmitElement() !== null && $this->getPressedSubmitElement()->getName() === 'delete') { + return true; + } + + return parent::hasBeenSubmitted(); + } + + /** @param iterable|Source $values */ + public function populate($values) + { + if ($values instanceof Source) { + $values = [ + 'name' => $values->name, + 'type' => $values->type, + 'icinga2_base_url' => $values->icinga2_base_url, + 'icinga2_auth_user' => $values->icinga2_auth_user, + 'icinga2_auth_pass' => $values->icinga2_auth_pass, + 'icinga2_ca_pem' => $values->icinga2_ca_pem, + 'icinga2_common_name' => $values->icinga2_common_name, + 'icinga2_insecure_tls' => $values->icinga2_insecure_tls + ]; + } + + parent::populate($values); + + return $this; + } + + protected function onSuccess(): void + { + $pressedButton = $this->getPressedSubmitElement(); + if ($pressedButton && $pressedButton->getName() === 'delete') { + $this->db->delete('source', ['id = ?' => $this->sourceId]); + + return; + } + + $source = $this->getValues(); + + /** @var ?string $listenerPassword */ + $listenerPassword = $this->getValue('listener_password'); + if ($listenerPassword) { + // Not using PASSWORD_DEFAULT, as the used algorithm should + // be kept in sync with what the daemon understands + $source['listener_password_hash'] = password_hash($listenerPassword, self::HASH_ALGORITHM); + } + + if ($this->sourceId === null) { + $this->db->insert('source', $source); + } else { + $this->db->update('source', $source, ['id = ?' => $this->sourceId]); + } + } +} diff --git a/configuration.php b/configuration.php index efb1f86ad..eb429ffc1 100644 --- a/configuration.php +++ b/configuration.php @@ -49,6 +49,24 @@ ] ); +$this->provideConfigTab( + 'channels', + [ + 'title' => $this->translate('Channels'), + 'label' => $this->translate('Channels'), + 'url' => 'channels' + ] +); + +$this->provideConfigTab( + 'sources', + [ + 'title' => $this->translate('Sources'), + 'label' => $this->translate('Sources'), + 'url' => 'sources' + ] +); + $section->add( N_('Incidents'), [ @@ -67,12 +85,3 @@ foreach ($cssFiles as $path) { $this->provideCssFile(ltrim(substr($path, strlen($cssDirectory)), DIRECTORY_SEPARATOR)); } - -$this->provideConfigTab( - 'channels', - [ - 'title' => $this->translate('Channels'), - 'label' => $this->translate('Channels'), - 'url' => 'channels' - ] -); diff --git a/library/Notifications/Widget/ItemList/SourceList.php b/library/Notifications/Widget/ItemList/SourceList.php new file mode 100644 index 000000000..57f80b2c3 --- /dev/null +++ b/library/Notifications/Widget/ItemList/SourceList.php @@ -0,0 +1,17 @@ + 'action-list']; + + protected function getItemClass(): string + { + return SourceListItem::class; + } +} diff --git a/library/Notifications/Widget/ItemList/SourceListItem.php b/library/Notifications/Widget/ItemList/SourceListItem.php new file mode 100644 index 000000000..8c4633c03 --- /dev/null +++ b/library/Notifications/Widget/ItemList/SourceListItem.php @@ -0,0 +1,50 @@ +getAttributes() + ->set('data-action-item', true); + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $visual->addHtml($this->item->getIcon()); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml(new Link( + $this->item->name, + Url::fromPath('notifications/source', ['id' => $this->item->id]), + ['class' => 'subject'] + )); + } + + protected function assembleHeader(BaseHtmlElement $header): void + { + $header->addHtml($this->createTitle()); + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->addHtml($this->createHeader()); + } +} diff --git a/test/php/application/forms/SourceFormTest.php b/test/php/application/forms/SourceFormTest.php new file mode 100644 index 000000000..7c5cc8db0 --- /dev/null +++ b/test/php/application/forms/SourceFormTest.php @@ -0,0 +1,20 @@ +assertSame( + PASSWORD_DEFAULT, + SourceForm::HASH_ALGORITHM, + 'PHP\'s default password hash algorithm changed. Consider adding support for it' + ); + } +}