Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions library/Icinga/Web/Form/ConfigForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<?php

// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test

  1. Install Fedora 42
  2. Apply https://github.com/Al2Klimov/ansible-icinga-fedora using snapshot packages
  3. yum remove icingaweb2-module-monitoring
  4. Upgrade to https://git.icinga.com/packages/icingaweb/-/jobs/905522
  5. Create /usr/share/icingaweb2/modules/test5480
  6. icingacli mod en test5480
  7. Visit /icingaweb2/test5480
  8. Input something
  9. Press enter
  10. cat /etc/icingaweb2/modules/test5480/config.ini (ok)
  11. chmod 0440 /etc/icingaweb2/modules/test5480/config.ini
  12. Repeat steps 7-9 (fails, prints helpful message)

So far so good...

/usr/share/icingaweb2/modules/test5480

library/Test5480/Form.php

<?php

namespace Icinga\Module\Test5480;

use Icinga\Web\Form\ConfigForm;

class Form extends ConfigForm
{
    protected function assemble()
    {
        $this->addElement('text', 'foo__bar');
    }
}

application/controllers/IndexController.php

<?php

namespace Icinga\Module\Test5480\Controllers;

use GuzzleHttp\Psr7\ServerRequest;
use Icinga\Module\Test5480\Form;
use ipl\Web\Compat\CompatController;

class IndexController extends CompatController
{
    public function indexAction(): void
    {
        $form = new Form($this->Config());
        $form->handleRequest(ServerRequest::fromGlobals());
        $this->addContent($form);
    }
}

{
/** @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,
Comment thread
jrauh01 marked this conversation as resolved.
) {
$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();
}
Comment thread
jrauh01 marked this conversation as resolved.

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
*/
Comment thread
jrauh01 marked this conversation as resolved.
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;
Comment thread
jrauh01 marked this conversation as resolved.
}
}

/**
* 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
Comment thread
jrauh01 marked this conversation as resolved.
{
$this->addElement('submit', static::SUBMIT_BUTTON_NAME, [
'label' => $this->translate('Store'),
'ignore' => true,
]);
}
}
108 changes: 108 additions & 0 deletions test/php/library/Icinga/Web/Form/ConfigFormTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// 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'));
}
}
Loading