diff --git a/README.txt b/README.txt index 9fb748b..8ecd65f 100644 --- a/README.txt +++ b/README.txt @@ -10,16 +10,14 @@ To set a site in read-only mode add this to setting.php: $settings['config_readonly'] = TRUE; To provide a whitelist of configuration that can be changed when in read-only -mode: +mode implement the hook: - $settings['config_readonly_whitelist'] = '/path/to/whitelist.yml'; - -The expected YAML should look something like this (wildcards allowed): - - ignore: - - ignorethis - - ignorethis.* - - ignorethis.*.storage + hook_config_readonly_whitelist_patterns() { + return [ + 'config_name.to.ignore', + 'wildcards*allowed', + ]; + } To lock production and not other environments, your code in settings.php might be a conditional on an environment variable like: diff --git a/config_readonly.links.menu.yml b/config_readonly.links.menu.yml deleted file mode 100644 index 8f00636..0000000 --- a/config_readonly.links.menu.yml +++ /dev/null @@ -1,6 +0,0 @@ -config_readonly.whitelist: - title: 'Configuration read-only whitelist' - description: 'Manage the configuration read-only whitelist.' - route_name: config_readonly.whitelist - menu_name: config_readonly_whitelist - parent: 'system.admin_config_development' diff --git a/config_readonly.routing.yml b/config_readonly.routing.yml deleted file mode 100644 index 3db42c9..0000000 --- a/config_readonly.routing.yml +++ /dev/null @@ -1,7 +0,0 @@ -config_readonly.whitelist: - path: '/admin/config/development/config-readonly-whitelist' - defaults: - _form: '\Drupal\config_readonly\Form\WhitelistForm' - _title: 'Configuration read-only whitelist' - requirements: - _permission: 'administer site configuration' diff --git a/config_readonly.services.yml b/config_readonly.services.yml index 8d15c34..9de5ca3 100644 --- a/config_readonly.services.yml +++ b/config_readonly.services.yml @@ -1,5 +1,6 @@ services: config_readonly_form_subscriber: class: Drupal\config_readonly\EventSubscriber\ReadOnlyFormSubscriber + arguments: ['@module_handler'] tags: - { name: event_subscriber } diff --git a/src/Config/ConfigReadonlyStorage.php b/src/Config/ConfigReadonlyStorage.php index aaf6898..bbd57ba 100644 --- a/src/Config/ConfigReadonlyStorage.php +++ b/src/Config/ConfigReadonlyStorage.php @@ -2,10 +2,12 @@ namespace Drupal\config_readonly\Config; +use Drupal\config_readonly\Exception\ConfigReadonlyStorageException; use Drupal\Core\Config\CachedStorage; use Drupal\Core\Config\ConfigImporter; use Drupal\Core\Config\StorageInterface; use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Lock\LockBackendInterface; use Drupal\Core\Site\Settings; use Drupal\config_readonly\ConfigReadonlyWhitelistTrait; @@ -45,11 +47,14 @@ class ConfigReadonlyStorage extends CachedStorage { * The lock backend to check if config imports are in progress. * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack * The request stack. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler to invoke hooks. */ - public function __construct(StorageInterface $storage, CacheBackendInterface $cache, LockBackendInterface $lock, RequestStack $request_stack) { + public function __construct(StorageInterface $storage, CacheBackendInterface $cache, LockBackendInterface $lock, RequestStack $request_stack, ModuleHandlerInterface $module_handler) { parent::__construct($storage, $cache); $this->lock = $lock; $this->requestStack = $request_stack; + $this->moduleHandler = $module_handler; } /** @@ -60,14 +65,15 @@ public function createCollection($collection) { $this->storage->createCollection($collection), $this->cache, $this->lock, - $this->requestStack + $this->requestStack, + $this->moduleHandler ); } /** * {@inheritdoc} * - * @throws Exception + * @throws ConfigReadonlyStorageException */ public function write($name, array $data) { $this->checkLock($name); @@ -77,7 +83,7 @@ public function write($name, array $data) { /** * {@inheritdoc} * - * @throws Exception + * @throws ConfigReadonlyStorageException */ public function delete($name) { $this->checkLock($name); @@ -87,17 +93,18 @@ public function delete($name) { /** * {@inheritdoc} * - * @throws Exception + * @throws ConfigReadonlyStorageException */ public function rename($name, $new_name) { - $this->checkLock(); + $this->checkLock($name); + $this->checkLock($new_name); return parent::rename($name, $new_name); } /** * {@inheritdoc} * - * @throws Exception + * @throws ConfigReadonlyStorageException */ public function deleteAll($prefix = '') { $this->checkLock(); @@ -129,7 +136,7 @@ protected function checkLock($name = '') { return; } - throw new \Exception('Your site configuration active store is currently locked.'); + throw new ConfigReadonlyStorageException('Your site configuration active store is currently locked.'); } } diff --git a/src/ConfigReadonlyServiceProvider.php b/src/ConfigReadonlyServiceProvider.php index cb0d277..1d68d92 100644 --- a/src/ConfigReadonlyServiceProvider.php +++ b/src/ConfigReadonlyServiceProvider.php @@ -30,7 +30,7 @@ public function alter(ContainerBuilder $container) { if ($container->getParameter('kernel.environment') !== 'install') { $definition = $container->getDefinition('config.storage'); $definition->setClass('Drupal\config_readonly\Config\ConfigReadonlyStorage'); - $definition->setArguments([new Reference('config.storage.active'), new Reference('cache.config'), new Reference('lock'), new Reference('request_stack')]); + $definition->setArguments([new Reference('config.storage.active'), new Reference('cache.config'), new Reference('lock'), new Reference('request_stack'), new Reference('module_handler')]); } } } diff --git a/src/ConfigReadonlyWhitelistTrait.php b/src/ConfigReadonlyWhitelistTrait.php index dce7cca..afda005 100644 --- a/src/ConfigReadonlyWhitelistTrait.php +++ b/src/ConfigReadonlyWhitelistTrait.php @@ -3,86 +3,38 @@ namespace Drupal\config_readonly; /** - * Class ConfigReadonlyWhitelistTrait. + * Trait ConfigReadonlyWhitelistTrait. * * @package Drupal\config_readonly */ trait ConfigReadonlyWhitelistTrait { /** + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** * An array to store the whitelist ignore patterns. * - * @var array + * @var string[] */ protected $patterns = []; /** * Get whitelist patterns. * - * @return array + * @return string[] * The whitelist patterns. */ - public function getPatterns() { + public function getWhitelistPatterns() { if (!$this->patterns) { - $this->patterns = $this->getPatternsFromWhitelistConfig(); + $this->patterns = $this->moduleHandler->invokeAll('config_readonly_whitelist_patterns'); } return $this->patterns; } /** - * Get patterns from whitelist config. - * - * Based off the code from PreviousNext drush cexy / cimy to ensure - * consistency of result and fully compatible with it to be able to - * combine the two tools. - * - * @return array - * The patterns. - * - * @link https://www.previousnext.com.au/blog/introducing-drush-cmi-tools - * Drush CMI Tools from PreviousNext. - */ - protected function getPatternsFromWhitelistConfig() { - $patterns = []; - $config = \Drupal::service('config.factory') - ->get('config_readonly.whitelist'); - $whitelist_patterns = $config->get('ignore'); - - if ($whitelist_patterns) { - foreach ($whitelist_patterns as $whitelist_pattern) { - $pattern = $this->getSinglePattern($whitelist_pattern); - if ($pattern) { - $patterns[] = $pattern; - } - } - } - return $patterns; - } - - /** - * Get single pattern from the whitelist. - * - * Based off the code from PreviousNext drush cexy / cimy to ensure - * consistency of result. - * - * @param string $pattern - * The raw pattern potentially with wildcard. - * - * @return string - * The pattern. - * - * @link https://www.previousnext.com.au/blog/introducing-drush-cmi-tools - * Drush CMI Tools from PreviousNext. - */ - protected function getSinglePattern($pattern) { - // Allow for accidental .yml extension. - if (substr($pattern, -4) === '.yml') { - $pattern = substr($pattern, 0, -4); - } - return '/' . str_replace('\*', '(.*)', preg_quote($pattern)) . '\.yml/'; - } - - /** * Check if the given name matches any whitelist pattern. * * @param string $name @@ -92,16 +44,12 @@ protected function getSinglePattern($pattern) { * Whether or not there is a match. */ public function matchesWhitelistPattern($name) { - // Allow for accidental .yml extension. - if (substr($name, -4) !== '.yml') { - $name .= '.yml'; - } - // Check for matches. - $patterns = $this->getPatterns(); + $patterns = $this->getWhitelistPatterns(); if ($patterns) { foreach ($patterns as $pattern) { - if (preg_match($pattern, $name)) { + $escaped = str_replace('\*', '.*', preg_quote($pattern, '/')); + if (preg_match('/^' . $escaped . '$/', $name)) { return TRUE; } } diff --git a/src/EventSubscriber/ReadOnlyFormSubscriber.php b/src/EventSubscriber/ReadOnlyFormSubscriber.php index c80a168..44e2d65 100644 --- a/src/EventSubscriber/ReadOnlyFormSubscriber.php +++ b/src/EventSubscriber/ReadOnlyFormSubscriber.php @@ -2,6 +2,7 @@ namespace Drupal\config_readonly\EventSubscriber; +use Drupal\Core\Extension\ModuleHandlerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Entity\EntityFormInterface; @@ -17,6 +18,16 @@ class ReadOnlyFormSubscriber implements EventSubscriberInterface { use ConfigReadonlyWhitelistTrait; /** + * ReadOnlyFormSubscriber constructor. + * + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler to invoke hooks. + */ + public function __construct(ModuleHandlerInterface $module_handler) { + $this->moduleHandler = $module_handler; + } + + /** * Form ids to mark as read only. */ protected $readOnlyFormIds = [ @@ -30,8 +41,7 @@ class ReadOnlyFormSubscriber implements EventSubscriberInterface { */ public function onFormAlter(ReadOnlyFormEvent $event) { // Check if the form is a ConfigFormBase or a ConfigEntityListBuilder. - $build_info = $event->getFormState()->getBuildInfo(); - $form_object = $build_info['callback_object']; + $form_object = $event->getFormState()->getFormObject(); $mark_form_read_only = $form_object instanceof ConfigFormBase || $form_object instanceof ConfigEntityListBuilder; if (!$mark_form_read_only) { @@ -46,7 +56,7 @@ public function onFormAlter(ReadOnlyFormEvent $event) { } // Don't block particular patterns. - if ($form_object instanceof EntityFormInterface) { + if ($mark_form_read_only && $form_object instanceof EntityFormInterface) { $entity = $form_object->getEntity(); $name = $entity->getConfigTarget(); if ($this->matchesWhitelistPattern($name)) { @@ -54,6 +64,31 @@ public function onFormAlter(ReadOnlyFormEvent $event) { } } + if ($mark_form_read_only && $form_object instanceof ConfigFormBase) { + + // This is a dirty hack to access the editable configuration. + // Look away! + $editable_config_names_hack = function (ConfigFormBase $config_form) { + // The getEditableConfigNames is part of the public api or Drupal 8. + // Since it will always remain private (as making it public in core + // would break all implementations) it will have to be called + // with a different name, or with this workaround. + $reflection = new \ReflectionMethod(get_class($config_form), 'getEditableConfigNames'); + $reflection->setAccessible(true); + return $reflection->invoke($config_form); + }; + + // Hopefully this will not be needed some day. + // See https://www.drupal.org/node/2095289 + // $editable_config = $form_object->ConfigNames(); + $editable_config = $editable_config_names_hack($form_object); + + // If all editable config is in the whitelist, do not block the form. + if ($editable_config == array_filter($editable_config, [$this, 'matchesWhitelistPattern'])) { + $mark_form_read_only = FALSE; + } + } + if ($mark_form_read_only) { $event->markFormReadOnly(); } diff --git a/src/Exception/ConfigReadonlyStorageException.php b/src/Exception/ConfigReadonlyStorageException.php new file mode 100644 index 0000000..5506f8d --- /dev/null +++ b/src/Exception/ConfigReadonlyStorageException.php @@ -0,0 +1,8 @@ +config('config_readonly.whitelist'); - $whitelist_ignores = $config->get('ignore'); - - // Initial number of whitelist ignores. - if (!$form_state->get('num_whitelist_ignores')) { - $existing_count = ($whitelist_ignores ? count($whitelist_ignores) : 1); - $form_state->set('num_whitelist_ignores', $existing_count); - } - - // Introduce and describe how to add whitelist ignores before the - // repeating fields rather than in the description of each field. - $form['whitelist_intro'] = [ - '#type' => 'item', - '#title' => $this->t('How to use this form'), - '#plain_text' => $this->t('Enter one or more configuration keys to whitelist. You may use wildcards.'), - ]; - $form['whitelist_examples'] = [ - '#title' => $this->t('Some examples include:'), - '#type' => 'item', - '#markup' => '
field.storage.node.webform_reference
webform.*
devel.*
', - ]; - - // Container and repeating whitelist ignore fields. - $form['whitelist_ignores'] = [ - '#type' => 'container', - ]; - for ($x = 0; $x < $form_state->get('num_whitelist_ignores'); $x++) { - $form['whitelist_ignores'][$x] = [ - '#type' => 'textfield', - '#title' => $this->t('Whitelist ignore @num', ['@num' => ($x + 1)]), - '#default_value' => (isset($whitelist_ignores[$x]) ? $whitelist_ignores[$x] : ''), - ]; - } - - // Add an action to the existing actions allow the user to add more items. - $form = parent::buildForm($form, $form_state); - $form['actions']['add_whitelist_ignore'] = [ - '#type' => 'submit', - '#id' => 'add_whitelist_ignore', - '#value' => $this->t('Add another item to the whitelist'), - '#weight' => '-10', - ]; - return $form; - } - - /** - * {@inheritdoc} - */ - public function submitForm(array &$form, FormStateInterface $form_state) { - $triggering_element = $form_state->getTriggeringElement(); - if ($triggering_element['#id'] == 'add_whitelist_ignore') { - $this->addNewFields($form, $form_state); - } - else { - $this->finalSubmit($form, $form_state); - } - } - - /** - * Handle adding new. - */ - private function addNewFields(array &$form, FormStateInterface $form_state) { - - // Add 1 to the number of whitelist ignores. - $num_whitelist_ignores = $form_state->get('num_whitelist_ignores'); - $form_state->set('num_whitelist_ignores', ($num_whitelist_ignores + 1)); - - // Rebuild the form. - $form_state->setRebuild(); - } - - /** - * Handle saving the config. - */ - public function finalSubmit(array &$form, FormStateInterface $form_state) { - $values = $form_state->getValues(); - $whitelist_ignores = array_filter($values['whitelist_ignores']); - - // Retrieve and save the configuration. - $this->config('config_readonly.whitelist') - ->set('ignore', $whitelist_ignores) - ->save(); - - parent::submitForm($form, $form_state); - } - -} diff --git a/src/Tests/ReadOnlyConfigWhitelistTest.php b/src/Tests/ReadOnlyConfigWhitelistTest.php index 3f68e7a..76e8cfe 100644 --- a/src/Tests/ReadOnlyConfigWhitelistTest.php +++ b/src/Tests/ReadOnlyConfigWhitelistTest.php @@ -2,36 +2,17 @@ namespace Drupal\config_readonly\Tests; -use Drupal\Tests\BrowserTestBase; +use Drupal\simpletest\WebTestBase; +use Symfony\Component\Yaml\Yaml; /** * Tests read-only module config whitelist functionality. * * @group ConfigReadOnly */ -class ReadOnlyConfigWhitelistTest extends BrowserTestBase { +class ReadOnlyConfigWhitelistTest extends ReadOnlyConfigTest { - public static $modules = ['config', 'config_readonly', 'node']; - - /** - * Set up the environment for the tests. - */ - public function setUp() { - parent::setUp(); - $this->adminUser = $this->createUser([], NULL, TRUE); - $this->drupalLogin($this->adminUser); - } - - /** - * Set configuration as read only. - */ - protected function turnOnReadOnlySetting() { - $settings['settings']['config_readonly'] = (object) [ - 'value' => TRUE, - 'required' => TRUE, - ]; - $this->writeSettings($settings); - } + public static $modules = ['config', 'config_readonly', 'node', 'config_readonly_whitelist_test']; /** * Ensure that the whitelist allows a read-only form to become saveable. @@ -46,11 +27,6 @@ public function testWhitelist() { 'name' => 'Article2', ]); - // Navigate to the config read-only whitelist settings page and add some - // items to the whitelist. - $this->drupalGet('admin/config/development/config-readonly-whitelist'); - $this->submitForm(['whitelist_ignores[0]' => 'article2'], 'Save configuration'); - $this->turnOnReadOnlySetting(); $this->drupalGet('admin/structure/types/manage/article1'); @@ -60,4 +36,19 @@ public function testWhitelist() { $this->assertNoText('This form will not be saved because the configuration active store is read-only.', 'Warning not show on edit node type page.'); } + public function testSimpleConfig() { + $this->drupalGet('admin/config/development/configuration/single/import'); + $this->assertNoText('This form will not be saved because the configuration active store is read-only.', 'Warning not shown on single config import page.'); + + $this->drupalGet('admin/config/development/performance'); + $this->assertNoText('This form will not be saved because the configuration active store is read-only.', 'Warning not shown on performance config page.'); + + $this->turnOnReadOnlySetting(); + $this->drupalGet('admin/config/development/configuration/single/import'); + $this->assertText('This form will not be saved because the configuration active store is read-only.', 'Warning shown on single config import page.'); + + $this->drupalGet('admin/config/development/performance'); + $this->assertNoText('This form will not be saved because the configuration active store is read-only.', 'Warning not shown on performance config page.'); + } + } diff --git a/tests/modules/config_readonly_whitelist_test/config_readonly_whitelist_test.info.yml b/tests/modules/config_readonly_whitelist_test/config_readonly_whitelist_test.info.yml new file mode 100644 index 0000000..6b6a890 --- /dev/null +++ b/tests/modules/config_readonly_whitelist_test/config_readonly_whitelist_test.info.yml @@ -0,0 +1,8 @@ +name: 'Config Readonly Whitelist test' +type: module +description: 'Provides test whitelist.' +package: Testing +version: VERSION +core: 8.x +dependencies: + - config_readonly diff --git a/tests/modules/config_readonly_whitelist_test/config_readonly_whitelist_test.module b/tests/modules/config_readonly_whitelist_test/config_readonly_whitelist_test.module new file mode 100644 index 0000000..9aab688 --- /dev/null +++ b/tests/modules/config_readonly_whitelist_test/config_readonly_whitelist_test.module @@ -0,0 +1,16 @@ +