From 59404d7909b21682ec0782778452a8a70e38b25e Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 18 May 2016 21:43:59 +0200 Subject: [PATCH] Introduce a configuration manager (not plugged yet) --- application/config/ConfigIO.php | 33 +++ application/config/ConfigManager.php | 363 +++++++++++++++++++++++++++ application/config/ConfigPhp.php | 93 +++++++ application/config/ConfigPlugin.php | 118 +++++++++ tests/config/ConfigManagerTest.php | 48 ++++ tests/config/ConfigPhpTest.php | 82 ++++++ tests/config/ConfigPluginTest.php | 121 +++++++++ tests/config/php/configOK.php | 14 ++ 8 files changed, 872 insertions(+) create mode 100644 application/config/ConfigIO.php create mode 100644 application/config/ConfigManager.php create mode 100644 application/config/ConfigPhp.php create mode 100644 application/config/ConfigPlugin.php create mode 100644 tests/config/ConfigManagerTest.php create mode 100644 tests/config/ConfigPhpTest.php create mode 100644 tests/config/ConfigPluginTest.php create mode 100644 tests/config/php/configOK.php diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php new file mode 100644 index 00000000..2b68fe6a --- /dev/null +++ b/application/config/ConfigIO.php @@ -0,0 +1,33 @@ +initialize(); + } + + return self::$instance; + } + + /** + * Rebuild the loaded config array from config files. + */ + public function reload() + { + $this->initialize(); + } + + /** + * Initialize loaded conf in ConfigManager. + */ + protected function initialize() + { + /*if (! file_exists(self::$CONFIG_FILE .'.php')) { + $this->configIO = new ConfigJson(); + } else { + $this->configIO = new ConfigPhp(); + }*/ + $this->configIO = new ConfigPhp(); + $this->loadedConfig = $this->configIO->read(self::$CONFIG_FILE); + $this->setDefaultValues(); + } + + /** + * Get a setting. + * + * Supports nested settings with dot separated keys. + * Eg. 'config.stuff.option' will find $conf[config][stuff][option], + * or in JSON: + * { "config": { "stuff": {"option": "mysetting" } } } } + * + * @param string $setting Asked setting, keys separated with dots. + * @param string $default Default value if not found. + * + * @return mixed Found setting, or the default value. + */ + public function get($setting, $default = '') + { + $settings = explode('.', $setting); + $value = self::getConfig($settings, $this->loadedConfig); + if ($value === self::$NOT_FOUND) { + return $default; + } + return $value; + } + + /** + * Set a setting, and eventually write it. + * + * Supports nested settings with dot separated keys. + * + * @param string $setting Asked setting, keys separated with dots. + * @param string $value Value to set. + * @param bool $write Write the new setting in the config file, default false. + * @param bool $isLoggedIn User login state, default false. + */ + public function set($setting, $value, $write = false, $isLoggedIn = false) + { + $settings = explode('.', $setting); + self::setConfig($settings, $value, $this->loadedConfig); + if ($write) { + $this->write($isLoggedIn); + } + } + + /** + * Check if a settings exists. + * + * Supports nested settings with dot separated keys. + * + * @param string $setting Asked setting, keys separated with dots. + * + * @return bool true if the setting exists, false otherwise. + */ + public function exists($setting) + { + $settings = explode('.', $setting); + $value = self::getConfig($settings, $this->loadedConfig); + if ($value === self::$NOT_FOUND) { + return false; + } + return true; + } + + /** + * Call the config writer. + * + * @param bool $isLoggedIn User login state. + * + * @throws MissingFieldConfigException: a mandatory field has not been provided in $conf. + * @throws UnauthorizedConfigException: user is not authorize to change configuration. + * @throws IOException: an error occurred while writing the new config file. + */ + public function write($isLoggedIn) + { + // These fields are required in configuration. + $mandatoryFields = array( + 'login', 'hash', 'salt', 'timezone', 'title', 'titleLink', + 'redirector', 'disablesessionprotection', 'privateLinkByDefault' + ); + + // Only logged in user can alter config. + if (is_file(self::$CONFIG_FILE) && !$isLoggedIn) { + throw new UnauthorizedConfigException(); + } + + // Check that all mandatory fields are provided in $conf. + foreach ($mandatoryFields as $field) { + if (! $this->exists($field)) { + throw new MissingFieldConfigException($field); + } + } + + $this->configIO->write(self::$CONFIG_FILE, $this->loadedConfig); + } + + /** + * Get the configuration file path. + * + * @return string Config file path. + */ + public function getConfigFile() + { + return self::$CONFIG_FILE . $this->configIO->getExtension(); + } + + /** + * Recursive function which find asked setting in the loaded config. + * + * @param array $settings Ordered array which contains keys to find. + * @param array $conf Loaded settings, then sub-array. + * + * @return mixed Found setting or NOT_FOUND flag. + */ + protected static function getConfig($settings, $conf) + { + if (!is_array($settings) || count($settings) == 0) { + return self::$NOT_FOUND; + } + + $setting = array_shift($settings); + if (!isset($conf[$setting])) { + return self::$NOT_FOUND; + } + + if (count($settings) > 0) { + return self::getConfig($settings, $conf[$setting]); + } + return $conf[$setting]; + } + + /** + * Recursive function which find asked setting in the loaded config. + * + * @param array $settings Ordered array which contains keys to find. + * @param mixed $value + * @param array $conf Loaded settings, then sub-array. + * + * @return mixed Found setting or NOT_FOUND flag. + */ + protected static function setConfig($settings, $value, &$conf) + { + if (!is_array($settings) || count($settings) == 0) { + return self::$NOT_FOUND; + } + + $setting = array_shift($settings); + if (count($settings) > 0) { + return self::setConfig($settings, $value, $conf[$setting]); + } + $conf[$setting] = $value; + } + + /** + * Set a bunch of default values allowing Shaarli to start without a config file. + */ + protected function setDefaultValues() + { + // Data subdirectory + $this->setEmpty('config.DATADIR', 'data'); + + // Main configuration file + $this->setEmpty('config.CONFIG_FILE', 'data/config.php'); + + // Link datastore + $this->setEmpty('config.DATASTORE', 'data/datastore.php'); + + // Banned IPs + $this->setEmpty('config.IPBANS_FILENAME', 'data/ipbans.php'); + + // Processed updates file. + $this->setEmpty('config.UPDATES_FILE', 'data/updates.txt'); + + // Access log + $this->setEmpty('config.LOG_FILE', 'data/log.txt'); + + // For updates check of Shaarli + $this->setEmpty('config.UPDATECHECK_FILENAME', 'data/lastupdatecheck.txt'); + + // Set ENABLE_UPDATECHECK to disabled by default. + $this->setEmpty('config.ENABLE_UPDATECHECK', false); + + // RainTPL cache directory (keep the trailing slash!) + $this->setEmpty('config.RAINTPL_TMP', 'tmp/'); + // Raintpl template directory (keep the trailing slash!) + $this->setEmpty('config.RAINTPL_TPL', 'tpl/'); + + // Thumbnail cache directory + $this->setEmpty('config.CACHEDIR', 'cache'); + + // Atom & RSS feed cache directory + $this->setEmpty('config.PAGECACHE', 'pagecache'); + + // Ban IP after this many failures + $this->setEmpty('config.BAN_AFTER', 4); + // Ban duration for IP address after login failures (in seconds) + $this->setEmpty('config.BAN_DURATION', 1800); + + // Feed options + // Enable RSS permalinks by default. + // This corresponds to the default behavior of shaarli before this was added as an option. + $this->setEmpty('config.ENABLE_RSS_PERMALINKS', true); + // If true, an extra "ATOM feed" button will be displayed in the toolbar + $this->setEmpty('config.SHOW_ATOM', false); + + // Link display options + $this->setEmpty('config.HIDE_PUBLIC_LINKS', false); + $this->setEmpty('config.HIDE_TIMESTAMPS', false); + $this->setEmpty('config.LINKS_PER_PAGE', 20); + + // Open Shaarli (true): anyone can add/edit/delete links without having to login + $this->setEmpty('config.OPEN_SHAARLI', false); + + // Thumbnails + // Display thumbnails in links + $this->setEmpty('config.ENABLE_THUMBNAILS', true); + // Store thumbnails in a local cache + $this->setEmpty('config.ENABLE_LOCALCACHE', true); + + // Update check frequency for Shaarli. 86400 seconds=24 hours + $this->setEmpty('config.UPDATECHECK_BRANCH', 'stable'); + $this->setEmpty('config.UPDATECHECK_INTERVAL', 86400); + + $this->setEmpty('redirector', ''); + $this->setEmpty('config.REDIRECTOR_URLENCODE', true); + + // Enabled plugins. + $this->setEmpty('config.ENABLED_PLUGINS', array('qrcode')); + + // Initialize plugin parameters array. + $this->setEmpty('plugins', array()); + } + + /** + * Set only if the setting does not exists. + * + * @param string $key Setting key. + * @param mixed $value Setting value. + */ + protected function setEmpty($key, $value) + { + if (! $this->exists($key)) { + $this->set($key, $value); + } + } +} + +/** + * Exception used if a mandatory field is missing in given configuration. + */ +class MissingFieldConfigException extends Exception +{ + public $field; + + /** + * Construct exception. + * + * @param string $field field name missing. + */ + public function __construct($field) + { + $this->field = $field; + $this->message = 'Configuration value is required for '. $this->field; + } +} + +/** + * Exception used if an unauthorized attempt to edit configuration has been made. + */ +class UnauthorizedConfigException extends Exception +{ + /** + * Construct exception. + */ + public function __construct() + { + $this->message = 'You are not authorized to alter config.'; + } +} diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php new file mode 100644 index 00000000..311aeb81 --- /dev/null +++ b/application/config/ConfigPhp.php @@ -0,0 +1,93 @@ +getExtension(); + if (! file_exists($filepath) || ! is_readable($filepath)) { + return array(); + } + + include $filepath; + + $out = array(); + foreach (self::$ROOT_KEYS as $key) { + $out[$key] = $GLOBALS[$key]; + } + $out['config'] = $GLOBALS['config']; + $out['plugins'] = !empty($GLOBALS['plugins']) ? $GLOBALS['plugins'] : array(); + return $out; + } + + /** + * @inheritdoc + */ + function write($filepath, $conf) + { + $filepath .= $this->getExtension(); + + $configStr = ' $value) { + $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL; + } + + if (isset($conf['plugins'])) { + foreach ($conf['plugins'] as $key => $value) { + $configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($conf['plugins'][$key], true).';'. PHP_EOL; + } + } + + // FIXME! + //$configStr .= 'date_default_timezone_set('.var_export($conf['timezone'], true).');'. PHP_EOL; + + if (!file_put_contents($filepath, $configStr) + || strcmp(file_get_contents($filepath), $configStr) != 0 + ) { + throw new IOException( + $filepath, + 'Shaarli could not create the config file. + Please make sure Shaarli has the right to write in the folder is it installed in.' + ); + } + } + + /** + * @inheritdoc + */ + function getExtension() + { + return '.php'; + } +} diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php new file mode 100644 index 00000000..8af89d04 --- /dev/null +++ b/application/config/ConfigPlugin.php @@ -0,0 +1,118 @@ + $data) { + if (startsWith($key, 'order')) { + continue; + } + + // If there is no order, it means a disabled plugin has been enabled. + if (isset($formData['order_' . $key])) { + $plugins[(int) $formData['order_' . $key]] = $key; + } + else { + $newEnabledPlugins[] = $key; + } + } + + // New enabled plugins will be added at the end of order. + $plugins = array_merge($plugins, $newEnabledPlugins); + + // Sort plugins by order. + if (!ksort($plugins)) { + throw new PluginConfigOrderException(); + } + + $finalPlugins = array(); + // Make plugins order continuous. + foreach ($plugins as $plugin) { + $finalPlugins[] = $plugin; + } + + return $finalPlugins; +} + +/** + * Validate plugin array submitted. + * Will fail if there is duplicate orders value. + * + * @param array $formData Data from submitted form. + * + * @return bool true if ok, false otherwise. + */ +function validate_plugin_order($formData) +{ + $orders = array(); + foreach ($formData as $key => $value) { + // No duplicate order allowed. + if (in_array($value, $orders)) { + return false; + } + + if (startsWith($key, 'order')) { + $orders[] = $value; + } + } + + return true; +} + +/** + * Affect plugin parameters values into plugins array. + * + * @param mixed $plugins Plugins array ($plugins[]['parameters']['param_name'] = . + * @param mixed $conf Plugins configuration. + * + * @return mixed Updated $plugins array. + */ +function load_plugin_parameter_values($plugins, $conf) +{ + $out = $plugins; + foreach ($plugins as $name => $plugin) { + if (empty($plugin['parameters'])) { + continue; + } + + foreach ($plugin['parameters'] as $key => $param) { + if (!empty($conf[$key])) { + $out[$name]['parameters'][$key] = $conf[$key]; + } + } + } + + return $out; +} + +/** + * Exception used if an error occur while saving plugin configuration. + */ +class PluginConfigOrderException extends Exception +{ + /** + * Construct exception. + */ + public function __construct() + { + $this->message = 'An error occurred while trying to save plugins loading order.'; + } +} diff --git a/tests/config/ConfigManagerTest.php b/tests/config/ConfigManagerTest.php new file mode 100644 index 00000000..1b6358f3 --- /dev/null +++ b/tests/config/ConfigManagerTest.php @@ -0,0 +1,48 @@ +conf = ConfigManager::getInstance(); + } + + public function tearDown() + { + @unlink($this->conf->getConfigFile()); + } + + public function testSetWriteGet() + { + // This won't work with ConfigPhp. + $this->markTestIncomplete(); + + $this->conf->set('paramInt', 42); + $this->conf->set('paramString', 'value1'); + $this->conf->set('paramBool', false); + $this->conf->set('paramArray', array('foo' => 'bar')); + $this->conf->set('paramNull', null); + + $this->conf->write(true); + $this->conf->reload(); + + $this->assertEquals(42, $this->conf->get('paramInt')); + $this->assertEquals('value1', $this->conf->get('paramString')); + $this->assertFalse($this->conf->get('paramBool')); + $this->assertEquals(array('foo' => 'bar'), $this->conf->get('paramArray')); + $this->assertEquals(null, $this->conf->get('paramNull')); + } + +} \ No newline at end of file diff --git a/tests/config/ConfigPhpTest.php b/tests/config/ConfigPhpTest.php new file mode 100644 index 00000000..0f849bd5 --- /dev/null +++ b/tests/config/ConfigPhpTest.php @@ -0,0 +1,82 @@ +configIO = new ConfigPhp(); + } + + /** + * Read a simple existing config file. + */ + public function testRead() + { + $conf = $this->configIO->read('tests/config/php/configOK'); + $this->assertEquals('root', $conf['login']); + $this->assertEquals('lala', $conf['redirector']); + $this->assertEquals('data/datastore.php', $conf['config']['DATASTORE']); + $this->assertEquals('1', $conf['plugins']['WALLABAG_VERSION']); + } + + /** + * Read a non existent config file -> empty array. + */ + public function testReadNonExistent() + { + $this->assertEquals(array(), $this->configIO->read('nope')); + } + + /** + * Write a new config file. + */ + public function testWriteNew() + { + $dataFile = 'tests/config/php/configWrite'; + $data = array( + 'login' => 'root', + 'redirector' => 'lala', + 'config' => array( + 'DATASTORE' => 'data/datastore.php', + ), + 'plugins' => array( + 'WALLABAG_VERSION' => '1', + ) + ); + $this->configIO->write($dataFile, $data); + $expected = 'assertEquals($expected, file_get_contents($dataFile .'.php')); + unlink($dataFile .'.php'); + } + + /** + * Overwrite an existing setting. + */ + public function testOverwrite() + { + $source = 'tests/config/php/configOK.php'; + $dest = 'tests/config/php/configOverwrite'; + copy($source, $dest . '.php'); + $conf = $this->configIO->read($dest); + $conf['redirector'] = 'blabla'; + $this->configIO->write($dest, $conf); + $conf = $this->configIO->read($dest); + $this->assertEquals('blabla', $conf['redirector']); + unlink($dest .'.php'); + } +} diff --git a/tests/config/ConfigPluginTest.php b/tests/config/ConfigPluginTest.php new file mode 100644 index 00000000..716631b0 --- /dev/null +++ b/tests/config/ConfigPluginTest.php @@ -0,0 +1,121 @@ + 2, // no plugin related + 'plugin2' => 0, // new - at the end + 'plugin3' => 0, // 2nd + 'order_plugin3' => 8, + 'plugin4' => 0, // 1st + 'order_plugin4' => 5, + ); + + $expected = array( + 'plugin3', + 'plugin4', + 'plugin2', + ); + + $out = save_plugin_config($data); + $this->assertEquals($expected, $out); + } + + /** + * Test save_plugin_config with invalid data. + * + * @expectedException PluginConfigOrderException + */ + public function testSavePluginConfigInvalid() + { + $data = array( + 'plugin2' => 0, + 'plugin3' => 0, + 'order_plugin3' => 0, + 'plugin4' => 0, + 'order_plugin4' => 0, + ); + + save_plugin_config($data); + } + + /** + * Test save_plugin_config without data. + */ + public function testSavePluginConfigEmpty() + { + $this->assertEquals(array(), save_plugin_config(array())); + } + + /** + * Test validate_plugin_order with valid data. + */ + public function testValidatePluginOrderValid() + { + $data = array( + 'order_plugin1' => 2, + 'plugin2' => 0, + 'plugin3' => 0, + 'order_plugin3' => 1, + 'plugin4' => 0, + 'order_plugin4' => 5, + ); + + $this->assertTrue(validate_plugin_order($data)); + } + + /** + * Test validate_plugin_order with invalid data. + */ + public function testValidatePluginOrderInvalid() + { + $data = array( + 'order_plugin1' => 2, + 'order_plugin3' => 1, + 'order_plugin4' => 1, + ); + + $this->assertFalse(validate_plugin_order($data)); + } + + /** + * Test load_plugin_parameter_values. + */ + public function testLoadPluginParameterValues() + { + $plugins = array( + 'plugin_name' => array( + 'parameters' => array( + 'param1' => true, + 'param2' => false, + 'param3' => '', + ) + ) + ); + + $parameters = array( + 'param1' => 'value1', + 'param2' => 'value2', + ); + + $result = load_plugin_parameter_values($plugins, $parameters); + $this->assertEquals('value1', $result['plugin_name']['parameters']['param1']); + $this->assertEquals('value2', $result['plugin_name']['parameters']['param2']); + $this->assertEquals('', $result['plugin_name']['parameters']['param3']); + } +} diff --git a/tests/config/php/configOK.php b/tests/config/php/configOK.php new file mode 100644 index 00000000..b91ad293 --- /dev/null +++ b/tests/config/php/configOK.php @@ -0,0 +1,14 @@ +