diff --git a/library/Icinga/Web/Form/ConfigForm.php b/library/Icinga/Web/Form/ConfigForm.php new file mode 100644 index 0000000000..d76d7e633a --- /dev/null +++ b/library/Icinga/Web/Form/ConfigForm.php @@ -0,0 +1,194 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Web\Form; + +use Exception; +use Icinga\Application\Config; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Widget\ShowConfiguration; +use ipl\Web\Compat\CompatForm; + +/** + * Form base-class providing standard functionality for configuration forms + */ +class ConfigForm extends CompatForm +{ + /** @var string Name of the submit button element */ + protected const SUBMIT_BUTTON_NAME = 'store'; + + /** + * A list of elements that should not be saved to the configuration + * + * @var string[] + */ + protected array $ignoredElements = [self::SUBMIT_BUTTON_NAME]; + + /** + * The configuration to work with + * + * @var Config|null + */ + protected ?Config $config = null; + + /** + * Set the configuration to use when populating and saving + * + * @param Config $config The configuration to use + * + * @return $this + */ + public function setConfig(Config $config): static + { + $this->config = $config; + + return $this; + } + + public function ensureAssembled(): static + { + if (! $this->hasBeenAssembled) { + parent::ensureAssembled(); + $this->populateFromConfig(); + } + + return $this; + } + + /** + * Populate the form elements from the configuration + * + * @return void + * + * @throws ProgrammingError + */ + protected function populateFromConfig(): void + { + if (! $this->config) { + throw new ProgrammingError("A config object must be set before populating the form."); + } + + $populate = []; + foreach ($this->getElements() as $element) { + [$section, $key] = $this->getIniKeyFromName($element->getName()); + if ($section === null || $key === null) { + continue; + } + $value = $this->getPopulatedValue($element->getName()) ?? $this->config->get($section, $key); + if ($value === null) { + continue; + } + $populate[$element->getName()] = $value; + } + $this->populate($populate); + } + + /** + * Get the section and key from the element name + * + * @param string $name The element name + * + * @return string[]|null + */ + protected function getIniKeyFromName(string $name): ?array + { + $parts = explode('__', $name, 2); + + if (count($parts) !== 2) { + return [null, null]; + } + + return $parts; + } + + /** + * Get the value of a configuration key from an element name + * + * @param string $name The element name + * @param mixed $default The default value to return if the config entry does not exist + * + * @return mixed The value of the configuration key or the default value + */ + public function getConfigValue(string $name, mixed $default = null): mixed + { + [$section, $key] = $this->getIniKeyFromName($name); + if ($section === null || $key === null) { + return $default; + } + + return $this->config->get($section, $key, $default); + } + + /** + * Persist the current configuration to disk + * + * If an error occurs, the form will be re-rendered with the error message + * and the raw INI configuration. + * + * @throws ProgrammingError + */ + protected function save(): void + { + if (! $this->config) { + throw new ProgrammingError("A config object must be set before saving the configuration."); + } + + foreach ($this->getElements() as $element) { + if (in_array($element->getName(), $this->ignoredElements)) { + continue; + } + [$section, $key] = $this->getIniKeyFromName($element->getName()); + if ($section === null || $key === null) { + continue; + } + + $value = $this->getPopulatedValue($element->getName()); + $configSection = $this->config->getSection($section); + if ((string) $value === '') { + unset($configSection[$key]); + } else { + $configSection->$key = $value; + } + + if ($configSection->isEmpty()) { + $this->config->removeSection($section); + } else { + $this->config->setSection($section, $configSection); + } + } + + $this->config->saveIni(); + } + + protected function onSuccess(): void + { + try { + $this->save(); + } catch (Exception $e) { + $content = $this->getContent(); + array_unshift( + $content, + new ShowConfiguration( + $e, + $this->config, + ) + ); + $this->setContent($content); + throw $e; + } + } + + /** + * Add the store button to the form + * + * @return void + */ + protected function addButtonElements(): void + { + $this->addElement('submit', static::SUBMIT_BUTTON_NAME, [ + 'label' => $this->translate('Store'), + ]); + } +} diff --git a/library/Icinga/Web/Form/ConfigSectionForm.php b/library/Icinga/Web/Form/ConfigSectionForm.php new file mode 100644 index 0000000000..ea4e9ddb56 --- /dev/null +++ b/library/Icinga/Web/Form/ConfigSectionForm.php @@ -0,0 +1,393 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Web\Form; + +use Exception; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Widget\ShowConfiguration; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Validator\CallbackValidator; + +class ConfigSectionForm extends ConfigForm +{ + /** @var string Name of the delete button element */ + protected const DELETE_BUTTON_NAME = 'delete'; + + /** @var string Name of the element containing the section name */ + protected const NAME_ELEMENT_NAME = 'name'; + + /** @var string Event emitted when the form has successfully deleted a configuration section */ + public const ON_DELETE = 'delete'; + + /** @var string Event emitted when the form has successfully renamed a configuration section */ + public const ON_RENAME = 'rename'; + + /** + * A list of elements that should not be saved to the configuration + * + * @var string[] + */ + protected array $ignoredElements = [self::SUBMIT_BUTTON_NAME, self::DELETE_BUTTON_NAME, self::NAME_ELEMENT_NAME]; + + /** + * The section to work with. + * If not set, the section is determined from the element name. + * + * @var string|null + */ + protected ?string $section = null; + + /** + * Whether the form is used for creating a new configuration section + * + * @var bool + */ + protected bool $isCreateForm = false; + + /** + * Whether the form allows deletion of the configuration section + * + * @var bool + */ + protected bool $allowDeletion = true; + + /** + * Whether the form allows renaming of the configuration section + * + * @var bool + */ + protected bool $allowRename = true; + + public function __construct() + { + $this->on(static::ON_SENT, function () { + if ($this->shouldDelete()) { + $this->handleDelete(); + $this->emit(static::ON_DELETE, [$this]); + } + }); + } + + protected function populateFromConfig(): void + { + if ($this->allowRename()) { + $this->populate([ + static::NAME_ELEMENT_NAME => $this->getPopulatedValue(static::NAME_ELEMENT_NAME, $this->section), + ]); + } + + parent::populateFromConfig(); + } + + public function isValidEvent($event): bool + { + if ($event === static::ON_DELETE || $event === static::ON_RENAME) { + return true; + } + + return parent::isValidEvent($event); + } + + /** + * Set the section to use when populating and saving + * + * @param string $section The section to use + * + * @return $this + */ + public function setSection(string $section): static + { + $this->section = $section; + + return $this; + } + + /** + * Set whether the form is used for creating a new configuration section, with a name that can be chosen by the user + * + * @param bool $create + * + * @return static + */ + public function setIsCreateForm(bool $create = true): static + { + $this->isCreateForm = $create; + + return $this; + } + + /** + * Is the form used for creating a new configuration section + * + * @return bool + */ + public function isCreateForm(): bool + { + return $this->isCreateForm; + } + + /** + * Set whether the form allows deletion of the configuration section + * + * @param bool $allowDeletion + * + * @return static + */ + public function setAllowDeletion(bool $allowDeletion = true): static + { + $this->allowDeletion = $allowDeletion; + + return $this; + } + + /** + * Whether the form is allowed to delete the configuration section + * + * Note: Creation forms are never allowed to be deleted. + * + * @return bool + */ + public function allowDeletion(): bool + { + if ($this->isCreateForm()) { + return false; + } + + return $this->allowDeletion; + } + + /** + * Set the ability to rename the configuration section + * + * @param bool $allowRename Whether the form is allowed to rename the configuration section + * + * @return $this + */ + public function setAllowRename(bool $allowRename = true): static + { + $this->allowRename = $allowRename; + + return $this; + } + + /** + * Whether the form is allowed to rename the configuration section + * + * Note: Creation forms are never allowed to be rename forms. + * + * @return bool + */ + public function allowRename(): bool + { + if ($this->isCreateForm()) { + return false; + } + + return $this->allowRename; + } + + /** + * Handle the deletion of the configuration section + * + * This method is called when the delete button is pressed. + * It deletes the underlying section regardless of whether form validation passed. + * This is done to allow for deletion of sections that contain invalid configuration. + * + * @return void + * + * @throws ProgrammingError + */ + protected function handleDelete(): void + { + if ($this->section === null) { + throw new ProgrammingError('Section must be set before deleting a configuration section.'); + } + + try { + $this->config->removeSection($this->section); + $this->config->saveIni(); + } catch (Exception $e) { + $content = $this->getContent(); + array_unshift( + $content, + new ShowConfiguration( + $e, + $this->config, + ) + ); + $this->setContent($content); + throw $e; + } + } + + /** + * Handle the renaming of the configuration section + * + * This method is called when the rename button is pressed. + * It renames the underlying section and updates the section name in the form. + * + * @return void + * + * @throws ProgrammingError + */ + protected function handleRename(): void + { + if (! $this->config) { + throw new ProgrammingError('Config must be set before renaming a configuration section.'); + } + + if ($this->section === null) { + throw new ProgrammingError('Section must be set before renaming a configuration section.'); + } + + $newName = $this->getPopulatedValue(static::NAME_ELEMENT_NAME); + $section = $this->config->getSection($this->section); + $this->config->removeSection($this->section); + $this->config->setSection($newName, $section); + $this->section = $newName; + } + + /** + * Check if the delete button has been pressed and the section should be deleted + * + * @return bool + */ + public function shouldDelete(): bool + { + if (! $this->hasDeleteButton()) { + return false; + } + + $deleteButton = $this->getElement(static::DELETE_BUTTON_NAME); + if (! ($deleteButton instanceof FormSubmitElement)) { + return false; + } + + return $deleteButton->hasBeenPressed(); + } + + public function hasDeleteButton(): bool + { + return $this->hasElement(static::DELETE_BUTTON_NAME); + } + + /** + * Add the section name element to the form + * + * This element is used to create a new configuration section with the given + * name. The added element automatically validates that the name is unique + * within the configuration. + * + * @param array $params Additional parameters to pass to the element constructor + * + * @return void + */ + protected function addSectionNameElement(array $params = []): void + { + if (! $this->isCreateForm() && ! $this->allowRename()) { + return; + } + + $params['required'] = true; + + if (! isset($params['label'])) { + $params['label'] = $this->translate('Name'); + } + + $uniqueValidator = new CallbackValidator(function ($value, CallbackValidator $validator) { + if (empty($value)) { + return true; + } + + if ($value == $this->section) { + return true; + } + + if ($this->config->hasSection($value)) { + $validator->addMessage(t('An entry with this name already exists.')); + return false; + } + + return true; + }); + + if (! isset($params['validators'])) { + $params['validators'] = []; + } + $params['validators'][] = $uniqueValidator; + + $this->addElement('text', static::NAME_ELEMENT_NAME, $params); + } + + protected function getIniKeyFromName(string $name): ?array + { + return [$this->section, $name]; + } + + protected function onSuccess(): void + { + if ($this->isCreateForm()) { + $this->section = $this->getValue(static::NAME_ELEMENT_NAME); + + if (empty($this->section)) { + throw new ProgrammingError('Section must be set before saving a new configuration section.'); + } + } + + $oldSection = $this->section; + $isRename = $this->shouldRename(); + + if ($isRename) { + $this->handleRename(); + } + + parent::onSuccess(); + + if ($isRename) { + $this->emit(static::ON_RENAME, [ + $this, + $oldSection, + $this->section, + ]); + } + } + + protected function addButtonElements(): void + { + parent::addButtonElements(); + + if (! $this->allowDeletion()) { + return; + } + + $deleteButton = $this->createElement( + 'submit', + static::DELETE_BUTTON_NAME, + [ + 'label' => $this->translate('Delete'), + 'formnovalidate' => true, + ], + ); + $this->registerElement($deleteButton); + $this->getElement(static::SUBMIT_BUTTON_NAME) + ->getWrapper() + ->prepend($deleteButton); + } + + /** + * Check if the form should rename the section for this request + * + * @return bool + */ + private function shouldRename(): bool + { + if (! $this->allowRename() || ! $this->hasBeenSubmitted() || ! $this->isValid()) { + return false; + } + + return $this->section !== $this->getPopulatedValue(static::NAME_ELEMENT_NAME); + } +} diff --git a/library/Icinga/Web/Widget/ShowConfiguration.php b/library/Icinga/Web/Widget/ShowConfiguration.php new file mode 100644 index 0000000000..261906dd80 --- /dev/null +++ b/library/Icinga/Web/Widget/ShowConfiguration.php @@ -0,0 +1,98 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Web\Widget; + +use Exception; +use Icinga\Application\Config; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\I18n\Translation; +use ipl\Web\Widget\CopyToClipboard; + +/** + * Widget to show the configuration that couldn't be saved. + */ +class ShowConfiguration extends BaseHtmlElement +{ + use Translation; + + protected $tag = 'div'; + + /** + * Create a new ShowConfiguration widget + * @param Exception $exception The exception that was thrown + * @param Config $config The configuration instance + */ + public function __construct( + protected Exception $exception, + protected Config $config, + ) { + } + + protected function assemble(): void + { + $this->addHtml(HtmlElement::create( + 'h4', + null, + t('Saving Configuration Failed!'), + )); + + $this->addHtml(HtmlElement::create( + 'p', + null, + [ + sprintf( + t("The file %s couldn't be stored. (Error: '%s')"), + $this->config->getConfigFile(), + $this->exception->getMessage(), + ), + HtmlString::create('
'), + t('This could have one or more of the following reasons:'), + ], + )); + + $this->addHtml(HtmlElement::create( + 'ul', + null, + [ + HtmlElement::create('li', null, t("You don't have file-system permissions to write to the file")), + HtmlElement::create('li', null, t('Something went wrong while writing the file')), + HtmlElement::create( + 'li', + null, + t("There's an application error preventing you from persisting the configuration"), + ), + ], + )); + + $this->addHtml(HtmlElement::create( + 'p', + null, + [ + t( + 'Details can be found in the application log. ' . + "(If you don't have access to this log, call your administrator in this case)", + ), + HtmlString::create('
'), + t('In case you can access the file by yourself, you can open it and insert the config manually:'), + ], + )); + + $code = HtmlElement::create('code', null, (string) $this->config); + CopyToClipboard::attachTo($code); + + $this->addHtml(HtmlElement::create( + 'p', + null, + HtmlElement::create( + 'pre', + null, + $code, + ), + )); + } +}