From 60812353d2a1dc8d122249778b96b1dd199e47bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 26 Jun 2026 11:52:13 +0200 Subject: [PATCH] Add ConfigForm as an ipl-forms replacement for the Zend-based ConfigForm CompatForm base class that uses a `section__key` element-name convention to map form fields directly to INI file entries. Populates elements from the config on assembly, writes changed values back on success (removing empty keys, reverting empty password fields to their stored value), and embeds the `ShowConfiguration` widget inline when saving fails. --- library/Icinga/Web/Form/ConfigForm.php | 217 ++++++++++++++++++ .../Icinga/Web/Form/ConfigFormTest.php | 108 +++++++++ 2 files changed, 325 insertions(+) create mode 100644 library/Icinga/Web/Form/ConfigForm.php create mode 100644 test/php/library/Icinga/Web/Form/ConfigFormTest.php diff --git a/library/Icinga/Web/Form/ConfigForm.php b/library/Icinga/Web/Form/ConfigForm.php new file mode 100644 index 0000000000..510190f73e --- /dev/null +++ b/library/Icinga/Web/Form/ConfigForm.php @@ -0,0 +1,217 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Web\Form; + +use Exception; +use Icinga\Application\Config; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\ValueCandidates; +use ipl\Html\HtmlElement; +use ipl\Html\ValidHtml; +use ipl\Stdlib\Str; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Compat\DisplayFormElement; +use ipl\Web\Widget\CopyToClipboard; +use LogicException; + +/** + * Form base-class providing standard functionality for configuration forms + * + * Element names follow a `section__key` convention: the part before the + * {@see $sectionKeyDelimiter} (default: `__`) is the INI section name and the + * part after is the configuration key within that section. Subclasses add + * elements whose names match this pattern; `populateFromConfig` and `save` + * use it to map form values to and from the INI file automatically. + */ +class ConfigForm extends CompatForm +{ + /** @var string Name of the submit button element */ + protected const SUBMIT_BUTTON_NAME = 'store'; + + /** + * Delimiter used to separate the section and key in the element name + * + * This is used to determine whether an element is a configuration key or a + * section. The default delimiter is '__', this is chosen to allow for section + * names that contain underscores. + * + * @var string + */ + protected string $sectionKeyDelimiter = '__'; + + /** + * Create a new configuration form + * + * @param Config $config The ini file configuration object to use for the form + */ + public function __construct( + protected Config $config, + ) { + $this->on(static::ON_ELEMENT_REGISTERED, function (FormElement $element) { + [$section, $key] = Str::symmetricSplit($element->getName(), $this->sectionKeyDelimiter, 2); + if ($key === null || $element->hasValue()) { + return; + } + + $configValue = $this->config->get($section, $key); + if ($configValue === null) { + return; + } + + if (! ($element instanceof ValueCandidates)) { + $element->setValue($configValue); + + return; + } + + $candidates = $element->getValueCandidates(); + if (empty($candidates)) { + // Initial render: no prior submission, set value and seed candidates + $element->setValue($configValue); + $element->setValueCandidates([$configValue]); + } else { + // POST: candidates were set by registerElement; append config value so + // PasswordElement's (count > 1) condition resolves DUMMYPASSWORD on re-render + $element->setValueCandidates(array_merge($candidates, [$configValue])); + } + }); + } + + /** + * Ensure that all required form elements are present + * + * @return $this + */ + public function ensureAssembled(): static + { + if (! $this->hasBeenAssembled) { + parent::ensureAssembled(); + $this->addRequiredElements(); + } + + return $this; + } + + /** + * Create a hint that explains a configuration save failure and how to fix it manually + * + * @param Exception $exception The exception thrown while saving + * @param Config $config The configuration that failed to save + * + * @return ValidHtml + */ + public static function createConfigurationErrorHint(Exception $exception, Config $config): ValidHtml + { + $code = HtmlElement::create('code', null, (string) $config); + CopyToClipboard::attachTo($code); + + return HtmlElement::create('div', null, [ + HtmlElement::create('h4', null, t('Saving Configuration Failed!')), + HtmlElement::create('p', null, [ + sprintf( + t("The file %s couldn't be stored. (Error: '%s')"), + $config->getConfigFile(), + $exception->getMessage(), + ), + HtmlElement::create('br'), + t('This could have one or more of the following reasons:'), + ]), + 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", + )), + ]), + 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)", + ), + HtmlElement::create('br'), + t('In case you can access the file by yourself, you can open it and insert the config manually:'), + ]), + HtmlElement::create('p', null, HtmlElement::create('pre', null, $code)), + ]); + } + + /** + * 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. + * + * @return void + * + * @throws LogicException If an array value is encountered in the form + */ + protected function save(): void + { + foreach ($this->getValues() as $element => $value) { + [$section, $key] = Str::symmetricSplit($element, $this->sectionKeyDelimiter, 2); + if ($key === null) { + continue; + } + + if (is_array($value)) { + throw new LogicException(sprintf('Cannot save element "%s": array values are not supported', $element)); + } + + $configSection = $this->config->getSection($section); + if (Str::isEmpty($value)) { + unset($configSection[$key]); + } else { + $configSection[$key] = $value; + } + + if ($configSection->isEmpty()) { + $this->config->removeSection($section); + } else { + $this->config->setSection($section, $configSection); + } + } + + $this->config->saveIni(); + } + + /** + * Handle the form submission + * + * If the form submission is successful, the configuration is saved to disk. + * If an error occurs, the form is re-rendered with the error message and the + * raw INI configuration. The original exception is re-thrown. + * + * @return void + */ + protected function onSuccess(): void + { + try { + $this->save(); + } catch (Exception $e) { + $content = $this->getContent(); + array_unshift($content, new DisplayFormElement(static::createConfigurationErrorHint($e, $this->config))); + $this->setContent($content); + + throw $e; + } + } + + /** + * Add the submit button to the form + * + * Called automatically during assembly. Subclasses may override this to add + * additional required elements but must call the parent implementation. + * + * @return void + */ + protected function addRequiredElements(): void + { + $this->addElement('submit', static::SUBMIT_BUTTON_NAME, [ + 'label' => $this->translate('Store'), + 'ignore' => true, + ]); + } +} diff --git a/test/php/library/Icinga/Web/Form/ConfigFormTest.php b/test/php/library/Icinga/Web/Form/ConfigFormTest.php new file mode 100644 index 0000000000..65203c3a82 --- /dev/null +++ b/test/php/library/Icinga/Web/Form/ConfigFormTest.php @@ -0,0 +1,108 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Web\Form; + +use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Test\BaseTestCase; +use Icinga\Web\Form\ConfigForm; +use ipl\Html\FormElement\PasswordElement; +use LogicException; + +class ConfigFormTest extends BaseTestCase +{ + private function makeForm(array $configData = []) + { + return new class(Config::fromArray($configData)) extends ConfigForm { + public function exposeSetSectionKeyDelimiter(string $delimiter): void + { + $this->sectionKeyDelimiter = $delimiter; + } + }; + } + + public function testSubmitButtonIsAddedAfterAssembly(): void + { + $form = $this->makeForm(); + $form->ensureAssembled(); + $this->assertTrue($form->hasElement('store')); + } + + public function testSaveThrowsForArrayElementValue(): void + { + $this->expectException(LogicException::class); + + $config = new class(new ConfigObject([])) extends Config { + public function saveIni($filePath = null, $fileMode = 0660): void {} + }; + + $form = new class($config) extends ConfigForm { + protected function assemble(): void + { + $this->addElement('select', 'mysection__key', [ + 'options' => ['a' => 'A', 'b' => 'B'], + 'multiple' => true, + ]); + } + + public function exposeSave(): void + { + $this->save(); + } + }; + $form->ensureAssembled(); + $form->populate(['mysection__key' => ['a', 'b']]); + $form->exposeSave(); + } + + public function testUnchangedPasswordElementRetainsConfigValueOnSave(): void + { + $config = new class(new ConfigObject(['mysection' => ['password' => 'secret']])) extends Config { + public function saveIni($filePath = null, $fileMode = 0660): void {} + }; + + $form = new class($config) extends ConfigForm { + protected function assemble(): void + { + $this->addElement('password', 'mysection__password'); + } + + public function exposeSave(): void + { + $this->save(); + } + }; + $form->populate(['mysection__password' => PasswordElement::DUMMYPASSWORD]); + $form->ensureAssembled(); + $form->exposeSave(); + + $this->assertSame('secret', $config->get('mysection', 'password')); + } + + public function testEmptySectionIsRemovedOnSave(): void + { + $config = new class(new ConfigObject(['mysection' => ['key' => 'value']])) extends Config { + public function saveIni($filePath = null, $fileMode = 0660): void {} + }; + + $form = new class($config) extends ConfigForm { + protected function assemble(): void + { + $this->addElement('text', 'mysection__key'); + } + + public function exposeSave(): void + { + $this->save(); + } + }; + $form->ensureAssembled(); + $form->populate(['mysection__key' => '']); + $form->exposeSave(); + + $this->assertFalse($config->hasSection('mysection')); + } +}