diff --git a/config/config.ini b/config/config.ini index 4bdd24277..72ec0985f 100755 --- a/config/config.ini +++ b/config/config.ini @@ -18,5 +18,15 @@ debug.enable = 1 debug.type = stream debug.target = /tmp/icinga2.debug.log +; Use ini store to store preferences on local disk [preferences] type=ini + +; Use database to store preference into mysql or postgres +;[preferences] +;type=db +;dbtype=pgsql +;dbhost=127.0.0.1 +;dbpassword=icingaweb +;dbuser=icingaweb +;dbname=icingaweb \ No newline at end of file diff --git a/etc/schema/preferences.mysql.sql b/etc/schema/preferences.mysql.sql new file mode 100644 index 000000000..c76df46e0 --- /dev/null +++ b/etc/schema/preferences.mysql.sql @@ -0,0 +1,7 @@ +create table `preferences`( + `username` VARCHAR(255) NOT NULL, + `preference` VARCHAR(100) NOT NULL, + `value` VARCHAR(255) NOT NULL, + + PRIMARY KEY(username, preference) +); \ No newline at end of file diff --git a/etc/schema/preferences.pgsql.sql b/etc/schema/preferences.pgsql.sql new file mode 100644 index 000000000..3b22a5600 --- /dev/null +++ b/etc/schema/preferences.pgsql.sql @@ -0,0 +1,7 @@ +create table "preferences"( + "username" VARCHAR(255) NOT NULL, + "preference" VARCHAR(100) NOT NULL, + "value" VARCHAR(255) NOT NULL, + + PRIMARY KEY(username, preference) +); \ No newline at end of file diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php index 8d376c8f3..074e30c29 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -29,6 +29,7 @@ namespace Icinga\Application; use Icinga\Authentication\Manager as AuthenticationManager; +use Icinga\Exception\ConfigurationError; use Icinga\User\Preferences; use Icinga\User; use Icinga\Web\Request; @@ -40,7 +41,7 @@ use Zend_Controller_Action_HelperBroker; use Zend_Controller_Router_Route; use Zend_Controller_Action_Helper_ViewRenderer; use Icinga\Web\View; -use Icinga\User\Preferences\StorageFactory; +use Icinga\User\Preferences\StoreFactory; use Icinga\User\Preferences\SessionStore; /** @@ -201,6 +202,12 @@ class Web extends ApplicationBootstrap return $this; } + /** + * Create user object and inject preference interface + * + * @throws ConfigurationError + * @return User + */ private function setupUser() { $authenticationManager = AuthenticationManager::getInstance( @@ -211,17 +218,24 @@ class Web extends ApplicationBootstrap ); if ($authenticationManager->isAuthenticated() === true) { + if ($this->getConfig()->preferences === null) { + throw new ConfigurationError('Preferences not configured in config.ini'); + } + $user = $authenticationManager->getUser(); $this->getConfig()->preferences->configPath = $this->getConfigDir('preferences'); - $preferenceStore = StorageFactory::create( + $preferenceStore = StoreFactory::create( $this->getConfig()->preferences, $user ); + // Needed to update values in user session $sessionStore = new SessionStore($authenticationManager->getSession()); + // Performance: Do not ask provider if we've preferences + // stored in session $initialPreferences = (count($sessionStore->load())) ? $sessionStore->load() : $preferenceStore->load(); @@ -232,6 +246,7 @@ class Web extends ApplicationBootstrap $user->setPreferences($preferences); + // TESTING $requestCounter = $user->getPreferences()->get('test.request.counter', 0); $requestCounter++; $user->getPreferences()->set('test.request.counter', $requestCounter); diff --git a/library/Icinga/User/Preferences/ChangeSet.php b/library/Icinga/User/Preferences/ChangeSet.php index c50942eb1..f40578134 100644 --- a/library/Icinga/User/Preferences/ChangeSet.php +++ b/library/Icinga/User/Preferences/ChangeSet.php @@ -34,21 +34,21 @@ namespace Icinga\User\Preferences; class ChangeSet { /** - * Items to update + * Stack of pending updates * * @var array */ private $update = array(); /** - * Items to delete + * Stack of pending delete operations * * @var array */ private $delete = array(); /** - * Items to create + * Stack of pending create operations * * @var array */ @@ -56,6 +56,7 @@ class ChangeSet /** * Push an update to stack + * * @param string $key * @param mixed $value */ @@ -66,6 +67,7 @@ class ChangeSet /** * Getter for pending updates + * * @return array */ public function getUpdate() @@ -84,6 +86,8 @@ class ChangeSet } /** + * Get pending delete operations + * * @return array */ public function getDelete() @@ -95,13 +99,18 @@ class ChangeSet * Push create operation to stack * * @param string $key - * @param mixed $value + * @param mixed $value */ public function appendCreate($key, $value) { $this->create[$key] = $value; } + /** + * Get pending create operations + * + * @return array + */ public function getCreate() { return $this->create; diff --git a/library/Icinga/User/Preferences/DbStore.php b/library/Icinga/User/Preferences/DbStore.php new file mode 100644 index 000000000..5e786ed21 --- /dev/null +++ b/library/Icinga/User/Preferences/DbStore.php @@ -0,0 +1,241 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 + * @author Icinga Development Team + */ +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\User\Preferences; + +use Icinga\User; +use SplSubject; +use Zend_Db_Adapter_Abstract; +use Icinga\Exception\ProgrammingError; +use Icinga\User\Preferences; + +/** + * Store user preferences in database + */ +class DbStore implements LoadInterface, FlushObserverInterface +{ + /** + * Column name for username + */ + const COLUMN_USERNAME = 'username'; + + /** + * Column name for preference + */ + const COLUMN_PREFERENCE = 'preference'; + + /** + * Column name for value + */ + const COLUMN_VALUE = 'value'; + + /** + * User object + * + * @var User + */ + private $user; + + /** + * Zend database adapter + * + * @var Zend_Db_Adapter_Abstract + */ + private $dbAdapter; + + /** + * Table name + * + * @var string + */ + private $table = 'preferences'; + + /** + * Setter for user + * + * @param User $user + */ + public function setUser(User $user) + { + $this->user = $user; + } + + /** + * Setter for db adapter + * + * @param Zend_Db_Adapter_Abstract $dbAdapter + */ + public function setDbAdapter(Zend_Db_Adapter_Abstract $dbAdapter) + { + $this->dbAdapter = $dbAdapter; + $this->dbAdapter->getProfiler()->setEnabled(true); + + } + + /** + * Setter for table + * + * @param string $table + */ + public function setTable($table) + { + $this->table = $table; + } + + /** + * Load preferences from source + * + * @return array + */ + public function load() + { + $res = $this->dbAdapter->select()->from($this->table) + ->where('username=?', $this->user->getUsername()) + ->query(); + + $out = array(); + + foreach ($res->fetchAll() as $row) { + $out[$row[self::COLUMN_PREFERENCE]] = $row[self::COLUMN_VALUE]; + } + + return $out; + } + + /** + * Helper to create zend db suitable where condition + * + * @param string $preference + * @return array + */ + private function createWhereCondition($preference) + { + return array( + self::COLUMN_USERNAME. '=?' => $this->user->getUsername(), + self::COLUMN_PREFERENCE. '=?' => $preference + ); + } + + /** + * Create operation + * + * @param string $preference + * @param mixed $value + * @return int + */ + private function doCreate($preference, $value) + { + return $this->dbAdapter->insert( + $this->table, + array( + self::COLUMN_USERNAME => $this->user->getUsername(), + self::COLUMN_PREFERENCE => $preference, + self::COLUMN_VALUE => $value + ) + ); + } + + /** + * Update operation + * + * @param string $preference + * @param mixed $value + * @return int + */ + private function doUpdate($preference, $value) + { + return $this->dbAdapter->update( + $this->table, + array( + self::COLUMN_VALUE => $value + ), + $this->createWhereCondition($preference) + ); + } + + /** + * Delete preference operation + * + * @param string $preference + * @return int + */ + private function doDelete($preference) + { + return $this->dbAdapter->delete( + $this->table, + $this->createWhereCondition($preference) + ); + } + + /** + * Receive update from subject + * + * @link http://php.net/manual/en/splobserver.update.php + * @param SplSubject $subject + * @throws ProgrammingError + */ + public function update(SplSubject $subject) + { + if (!$subject instanceof Preferences) { + throw new ProgrammingError('Not compatible with '. get_class($subject)); + } + + $changeSet = $subject->getChangeSet(); + + foreach ($changeSet->getCreate() as $key => $value) { + $retVal = $this->doCreate($key, $value); + + if (!$retVal) { + throw new ProgrammingError('Could not create preference value in db: '. $key. '='. $value); + } + } + + foreach ($changeSet->getUpdate() as $key => $value) { + $retVal = $this->doUpdate($key, $value); + + /* + * Fallback if we switch storage type while user logged in + */ + if (!$retVal) { + $retVal = $this->doCreate($key, $value); + + if (!$retVal) { + throw new ProgrammingError('Could not create preference value in db: '. $key. '='. $value); + } + } + } + + foreach ($changeSet->getDelete() as $key) { + $retVal = $this->doDelete($key); + + if (!$retVal) { + throw new ProgrammingError('Could not delete preference value in db: '. $key); + } + } + } +} diff --git a/library/Icinga/User/Preferences/StorageFactory.php b/library/Icinga/User/Preferences/StoreFactory.php similarity index 71% rename from library/Icinga/User/Preferences/StorageFactory.php rename to library/Icinga/User/Preferences/StoreFactory.php index 84e2b853f..46c46be33 100644 --- a/library/Icinga/User/Preferences/StorageFactory.php +++ b/library/Icinga/User/Preferences/StoreFactory.php @@ -31,8 +31,12 @@ namespace Icinga\User\Preferences; use Icinga\User; use Icinga\Exception\ProgrammingError; use \Zend_Config; +use \Zend_Db; -final class StorageFactory +/** + * Create preference stores from zend config + */ +final class StoreFactory { /** * Prefix for classes containing namespace @@ -66,6 +70,31 @@ final class StorageFactory $items = $config->toArray(); unset($items['type']); + // TODO(mh): Encapsulate into a db adapter factory (#4503) + if (isset($items['dbname']) + && isset($items['dbuser']) + && isset($items['dbpassword']) + && isset($items['dbhost']) + && isset($items['dbtype']) + ) { + $zendDbType = 'PDO_'. strtoupper($items['dbtype']); + + $zendDbOptions = array( + 'host' => $items['dbhost'], + 'username' => $items['dbuser'], + 'password' => $items['dbpassword'], + 'dbname' => $items['dbname'] + ); + + if (isset($items['port'])) { + $zendDbOptions['port'] = $items['port']; + } + + $dbAdapter = Zend_Db::factory($zendDbType, $zendDbOptions); + + $items['dbAdapter'] = $dbAdapter; + } + foreach ($items as $key => $value) { $setter = 'set'. ucfirst($key); if (is_callable(array($store, $setter))) { diff --git a/test/php/library/Icinga/User/Preferences/DbStoreTest.php b/test/php/library/Icinga/User/Preferences/DbStoreTest.php new file mode 100644 index 000000000..f21e980a8 --- /dev/null +++ b/test/php/library/Icinga/User/Preferences/DbStoreTest.php @@ -0,0 +1,169 @@ + '127.0.0.1', + 'username' => 'icinga_unittest', + 'password' => 'icinga_unittest', + 'dbname' => 'icinga_unittest' + ); + + /** + * @var Zend_Db_Adapter_Abstract + */ + private $dbMysql; + + /** + * @var Zend_Db_Adapter_Abstract + */ + private $dbPgsql; + + private function createDb($type) + { + $zendType = 'PDO_'. strtoupper($type); + + if ($type === self::TYPE_MYSQL) { + $this->databaseConfig['port'] = 3306; + } elseif ($type === self::TYPE_PGSQL) { + $this->databaseConfig['port'] = 5432; + } + + $db = Zend_Db::factory( + $zendType, + $this->databaseConfig + ); + + try { + $db->getConnection(); + + $dumpFile = realpath(__DIR__. '/../../../../../../etc/schema/preferences.'. strtolower($type). '.sql'); + + if (!$dumpFile) { + throw new Exception('Dumpfile for db type not found: '. $type); + } + + try { + $db->getConnection()->exec(file_get_contents($dumpFile)); + } catch (PDOException $e) { + // PASS + } + + } catch (\Zend_Db_Adapter_Exception $e) { + return null; + } catch (PDOException $e) { + return null; + } + + return $db; + } + + protected function setUp() + { + $this->dbMysql = $this->createDb(self::TYPE_MYSQL); + $this->dbPgsql = $this->createDb(self::TYPE_PGSQL); + } + + protected function tearDown() + { + if ($this->dbMysql) { + $this->dbMysql->getConnection()->exec('DROP TABLE '. $this->table); + } + + if ($this->dbPgsql) { + $this->dbPgsql->getConnection()->exec('DROP TABLE '. $this->table); + } + + } + + private function createDbStore(Zend_Db_Adapter_Abstract $db) + { + $user = new User('jdoe'); + + $store = new DbStore(); + $store->setDbAdapter($db); + $store->setUser($user); + + return $store; + } + + public function testCreateUpdateDeletePreferenceValuesMySQL() + { + if ($this->dbMysql) { + $store = $this->createDbStore($this->dbMysql); + + $preferences = new Preferences(array()); + $preferences->attach($store); + + $preferences->set('test.key1', 'OK1'); + $preferences->set('test.key2', 'OK2'); + $preferences->set('test.key3', 'OK2'); + + $preferences->remove('test.key2'); + + $preferences->set('test.key3', 'OKOK333'); + + $preferencesTest = new Preferences($store->load()); + $this->assertEquals('OK1', $preferencesTest->get('test.key1')); + $this->assertNull($preferencesTest->get('test.key2')); + $this->assertEquals('OKOK333', $preferencesTest->get('test.key3')); + } else { + $this->markTestSkipped('MySQL test environment is not configured'); + } + } + + public function testCreateUpdateDeletePreferenceValuesPgSQL() + { + if ($this->dbPgsql) { + $store = $this->createDbStore($this->dbPgsql); + + $preferences = new Preferences(array()); + $preferences->attach($store); + + $preferences->set('test.key1', 'OK1'); + $preferences->set('test.key2', 'OK2'); + $preferences->set('test.key3', 'OK2'); + + $preferences->remove('test.key2'); + + $preferences->set('test.key3', 'OKOK333'); + + $preferencesTest = new Preferences($store->load()); + $this->assertEquals('OK1', $preferencesTest->get('test.key1')); + $this->assertNull($preferencesTest->get('test.key2')); + $this->assertEquals('OKOK333', $preferencesTest->get('test.key3')); + } else { + $this->markTestSkipped('PgSQL test environment is not configured'); + } + } + +} \ No newline at end of file