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.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 9601e56..afda005 100644 --- a/src/ConfigReadonlyWhitelistTrait.php +++ b/src/ConfigReadonlyWhitelistTrait.php @@ -2,117 +2,39 @@ namespace Drupal\config_readonly; -use Drupal\Component\Serialization\Yaml; -use Drupal\Core\Site\Settings; -use Drupal\config_readonly\Exception\ConfigReadonlyWhitelistException; - /** - * 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->getPatternsFromWhitelistFile(); + $this->patterns = $this->moduleHandler->invokeAll('config_readonly_whitelist_patterns'); } return $this->patterns; } /** - * Get patterns from config whitelist file. - * - * 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 getPatternsFromWhitelistFile() { - $patterns = []; - $whitelist_patterns = []; - - // Get the whitelist path from settings.php. - $whitelist = Settings::get('config_readonly_whitelist'); - - // In case whitelist file path is not provided. - if (!$whitelist) { - return $patterns; - } - - // Ensure the file path is to a valid file. - if (!is_file($whitelist)) { - $error = 'The config read-only whitelist file does not appear to exist.'; - throw new ConfigReadonlyWhitelistException($error); - } - - // Attempt to get the file contents and decode. - if ($string = file_get_contents($whitelist)) { - $parsed = FALSE; - try { - $parsed = Yaml::decode($string); - } - catch (InvalidDataTypeException $e) { - $error = 'Unable to parse the config read-only whitelist file.'; - throw new ConfigReadonlyWhitelistException($error); - } - $whitelist_patterns = $parsed['ignore']; - } - - // Clean up the found patterns. - 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 @@ -122,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/ConfigReadonlyWhitelistException.php b/src/Exception/ConfigReadonlyStorageException.php similarity index 68% rename from src/Exception/ConfigReadonlyWhitelistException.php rename to src/Exception/ConfigReadonlyStorageException.php index 34b7035..5506f8d 100644 --- a/src/Exception/ConfigReadonlyWhitelistException.php +++ b/src/Exception/ConfigReadonlyStorageException.php @@ -5,4 +5,4 @@ /** * Defines an exception thrown when unable to read or parse the whitelist. */ -class ConfigReadonlyWhitelistException extends \Exception {} +class ConfigReadonlyStorageException extends \Exception {} diff --git a/src/Tests/ReadOnlyConfigTest.php b/src/Tests/ReadOnlyConfigTest.php index 7a23958..4406984 100644 --- a/src/Tests/ReadOnlyConfigTest.php +++ b/src/Tests/ReadOnlyConfigTest.php @@ -47,7 +47,7 @@ public function testModulePages() { $this->drupalGet('admin/modules/uninstall'); $this->assertNoText('This form will not be saved because the configuration active store is read-only.', 'Warning not shown on modules uninstall page.'); $edit = [ - 'modules[Core][action][enable]' => 'action', + 'modules[action][enable]' => 'action', ]; $this->drupalPostForm('admin/modules', $edit, t('Install')); $this->assertNoText('This form will not be saved because the configuration active store is read-only.', 'Able to install a module.'); diff --git a/src/Tests/ReadOnlyConfigWhitelistTest.php b/src/Tests/ReadOnlyConfigWhitelistTest.php index 1854d25..76e8cfe 100644 --- a/src/Tests/ReadOnlyConfigWhitelistTest.php +++ b/src/Tests/ReadOnlyConfigWhitelistTest.php @@ -10,43 +10,9 @@ * * @group ConfigReadOnly */ -class ReadOnlyConfigWhitelistTest extends WebTestBase { +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); - } - - /** - * Turn on the whitelist functionality. - */ - protected function turnOnWhitelist() { - // Create the whitelist yml file. - $yml = [ - 'ignore' => ['article2'], - ]; - $file = $this->getTempFilesDirectory() . 'test-whitelist.yml'; - file_put_contents($file, Yaml::dump($yml)); - - // Set read only mode. - $settings['settings']['config_readonly'] = (object) [ - 'value' => TRUE, - 'required' => TRUE, - ]; - - // Set the whitelist file path. - $settings['settings']['config_readonly_whitelist'] = (object) [ - 'value' => $file, - '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. @@ -61,7 +27,7 @@ public function testWhitelist() { 'name' => 'Article2', ]); - $this->turnOnWhitelist(); + $this->turnOnReadOnlySetting(); $this->drupalGet('admin/structure/types/manage/article1'); $this->assertText('This form will not be saved because the configuration active store is read-only.', 'Warning shown on edit node type page.'); @@ -70,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 @@ +