* Copyright 2001-2012 Strangecode, LLC * * This file is part of The Strangecode Codebase. * * The Strangecode Codebase is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as published by the * Free Software Foundation, either version 3 of the License, or (at your option) * any later version. * * The Strangecode Codebase is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along with * The Strangecode Codebase. If not, see . */ /** * Prefs.inc.php * * Prefs provides an API for saving arbitrary values in a user's session, in cookies, and in the database. * Prefs can be stored into a database with the optional save() and load() methods. * * @author Quinn Comendant * @version 3.0 * @todo This class could really benefit from being refactored using the factory pattern, with backend storage mechanisms. * * Example of use (database storagetype): --------------------------------------------------------------------- // Load preferences for the user's session. require_once 'codebase/lib/Prefs.inc.php'; $prefs = new Prefs('my-namespace'); $prefs->setParam(array( 'storagetype' => ($auth->isLoggedIn() ? 'database' : 'session'), 'user_id' => $auth->get('user_id'), )); $prefs->setDefaults(array( 'search_num_results' => 25, 'datalog_num_entries' => 25, )); $prefs->load(); // Update preferences. Make sure to validate this input first! $prefs->set('search_num_results', getFormData('search_num_results')); $prefs->set('datalog_num_entries', getFormData('datalog_num_entries')); $prefs->save(); --------------------------------------------------------------------- */ class Prefs { // Namespace of this instance of Prefs. protected $_ns; // Configuration parameters for this object. protected $_params = array( // Store preferences in one of the available storage mechanisms: session, cookie, database // This default should remain set to 'session' for legacy support. 'storagetype' => 'session', // This parameter is only used for legacy support, superseded by the 'storagetype' setting. // Enable database storage. If this is false, all prefs will live only as long as the session. 'persistent' => null, // ---------------------------------------------------------- // Cookie-type settings. // Lifespan of the cookie. If set to an integer, interpreted as a timestamp (0 for 'when user closes browser'), otherwise as a strtotime-compatible value ('tomorrow', etc). 'cookie_expire' => '+10 years', // The path on the server in which the cookie will be available on. 'cookie_path' => '/', // The domain that the cookie is available to. 'cookie_domain' => null, // ---------------------------------------------------------- // Database-type settings. // The current user_id for which to load/save database-backed preferences. 'user_id' => null, // How long before we force a reload of the persistent prefs data? 'load_timeout' => 60, // Name of database table to store prefs. 'db_table' => 'pref_tbl', // Automatically create table and verify columns. Better set to false after site launch. // This value is overwritten by the $app->getParam('db_create_tables') setting if it is available. 'create_table' => true, // Original namespace set during __construct(). // 'namespace' => '', ); /** * Prefs constructor. */ public function __construct($namespace='', array $params=null) { $app =& App::getInstance(); $this->_ns = $namespace; // Save the original namespace for the DB pref_namespace column. $this->_ns, used in the SESSION variable, will change based on the user_id. $this->setParam(array('namespace' => $namespace)); // Get create tables config from global context. if (!is_null($app->getParam('db_create_tables'))) { $this->setParam(array('create_table' => $app->getParam('db_create_tables'))); } // Optional initial params. $this->setParam($params); if (isset($params['save_on_shutdown']) && $params['save_on_shutdown']) { // Run Prefs->save() upon script completion if we're using the database storagetype. // This only works if: // - 'storagetype' is provided as a parameter to the constructor rather than via setParam() later. // - $app->stop() is not called at the end of the script (which would close the PDO connection before the shutdown function runs). if ('database' == $this->getParam('storagetype')) { register_shutdown_function(array($this, 'save')); } } } /** * Setup the database table for this class. * * @access public * @author Quinn Comendant * @since 04 Jun 2006 16:41:42 */ public function initDB($recreate_db=false) { $app =& App::getInstance(); $pdo =& \Strangecode\Codebase\PDO::getInstance(); static $_db_tested = false; if ($recreate_db || !$_db_tested && $this->getParam('create_table')) { if ($recreate_db) { $pdo->query(sprintf("DROP TABLE IF EXISTS `%s`", $pdo->sanitizeIdentifier($this->getParam('db_table')))); $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_table')), LOG_INFO, __FILE__, __LINE__); } $stmt = $pdo->query(sprintf(" CREATE TABLE IF NOT EXISTS `%s` ( `user_id` VARCHAR(32) NOT NULL DEFAULT '', `pref_namespace` VARCHAR(32) NOT NULL DEFAULT '', `pref_key` VARCHAR(64) NOT NULL DEFAULT '', `pref_value` TEXT, PRIMARY KEY (`user_id`, `pref_namespace`, `pref_key`) ) ", $pdo->sanitizeIdentifier($this->getParam('db_table')))); if (!$pdo->columnExists($this->getParam('db_table'), array( 'user_id', 'pref_namespace', 'pref_key', 'pref_value', ), false, false)) { $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), LOG_ALERT, __FILE__, __LINE__); trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), E_USER_ERROR); } } $_db_tested = true; } /** * Set the params of this object. * * @param array $params Array of param keys and values to set. */ public function setParam(array $params=null) { $app =& App::getInstance(); // CLI scripts can't use prefs stored in HTTP-based protocols. if ($app->isCLI() && isset($params['storagetype']) && in_array($params['storagetype'], array('cookie', 'session'))) { $app->logMsg(sprintf('Storage type %s not available for CLI', $params['storagetype']), LOG_DEBUG, __FILE__, __LINE__); } // Convert the legacy param 'persistent' to 'storagetype=database'. // Old sites would set 'persistent' to true (use database) or false (use sessions). // If it is true, we set storagetype=database here. // If false, we rely on the default, sessions (which is assigned in the params). if (isset($params['persistent']) && $params['persistent'] && !isset($params['storagetype'])) { $params['storagetype'] = 'database'; } // Append the user_id to the namespace to keep separate collections for different users. if (isset($params['user_id']) && $params['user_id']) { // $this->getParam('namespace') should always be available since it is set in __construct(). $this->_ns = sprintf('%s-%s', $this->getParam('namespace'), $params['user_id']); } // Check max DB string lengths. if ((isset($params['storagetype']) && $params['storagetype'] == 'database') || $this->getParam('storagetype') == 'database') { if (isset($params['user_id']) && mb_strlen($params['user_id']) > 32) { $app->logMsg(sprintf('Prefs user_id param longer than 32 characters: %s', $params['user_id']), LOG_ERR, __FILE__, __LINE__); } if (isset($params['namespace']) && mb_strlen($params['namespace']) > 32) { $app->logMsg(sprintf('Prefs namespace longer than 32 characters: %s', $this->_ns), LOG_ERR, __FILE__, __LINE__); } if (isset($params['pref_key']) && mb_strlen($params['pref_key']) > 64) { $app->logMsg(sprintf('Prefs pref_key param longer than 64 characters: %s', $params['pref_key']), LOG_ERR, __FILE__, __LINE__); } } if (isset($params) && is_array($params)) { // Merge new parameters with old overriding only those passed. $this->_params = array_merge($this->_params, $params); } } /** * Return the value of a parameter, if it exists. * * @access public * @param string $param Which parameter to return. * @return mixed Configured parameter value. */ public function getParam($param) { $app =& App::getInstance(); if (array_key_exists($param, $this->_params)) { return $this->_params[$param]; } else { $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__); return null; } } /* * Setup the SESSION storage array. This method is called at the beginning of each method that accesses $_SESSION['_prefs']. * * @access public * @param * @return * @author Quinn Comendant * @since 02 Oct 2018 15:35:09 */ private function _init() { if ('cookie' != $this->getParam('storagetype') && !isset($_SESSION['_prefs'][$this->_ns])) { $this->clear(); } } /** * Sets the default values for preferences. If a preference is not explicitly * set, the value set here will be used. Can be called multiple times to merge additional * defaults together. This is mostly only useful for the database storagetype, when you have * values you want to use as default, and those are not stored to the database (so the defaults * can be changed later and apply to all users who haven't make s specific setting). * For the cookie storagetype, using setDefaults just sets cookies but only if a cookie with * the same name is not already set. * * @param array $defaults Array of key-value pairs */ public function setDefaults($defaults) { $app =& App::getInstance(); $this->_init(); if (isset($defaults) && is_array($defaults)) { switch ($this->getParam('storagetype')) { case 'session': case 'database': $_SESSION['_prefs'][$this->_ns]['defaults'] = array_merge($_SESSION['_prefs'][$this->_ns]['defaults'], $defaults); break; case 'cookie': foreach ($defaults as $key => $val) { if (!$this->exists($key)) { $this->set($key, $val); } } unset($key, $val); break; } } else { $app->logMsg(sprintf('Wrong data-type passed to Prefs->setDefaults().', null), LOG_NOTICE, __FILE__, __LINE__); } } /** * Store a key-value pair. * When using the database storagetype, if the value is different than what is set by setDefaults the value will be scheduled to be saved in the database. * * @param string $key The name of the preference to modify. * @param string $val The new value for this preference. */ public function set($key, $val) { $app =& App::getInstance(); if (!is_string($key)) { $app->logMsg(sprintf('Key is not a string-compatible type (%s)', getDump($key)), LOG_NOTICE, __FILE__, __LINE__); return false; } if ('' == trim($key)) { $app->logMsg(sprintf('Key is empty (along with value: %s)', $val), LOG_NOTICE, __FILE__, __LINE__); return false; } if (!is_scalar($val) && !is_array($val) && !is_object($val)) { $app->logMsg(sprintf('Value is not a compatible data type (%s=%s)', $key, getDump($val)), LOG_WARNING, __FILE__, __LINE__); return false; } $this->_init(); switch ($this->getParam('storagetype')) { // Both session and database prefs are saved in the session (for database, only temporarily until they are saved). case 'session': case 'database': // Set a saved preference if... // - there isn't a default. // - or the new value is different than the default // - or there is a previously existing saved key. if (!(isset($_SESSION['_prefs'][$this->_ns]['defaults']) && array_key_exists($key, $_SESSION['_prefs'][$this->_ns]['defaults'])) || $_SESSION['_prefs'][$this->_ns]['defaults'][$key] != $val || $this->exists($key)) { $_SESSION['_prefs'][$this->_ns]['saved'][$key] = $val; $app->logMsg(sprintf('Setting session/database preference %s => %s', $key, getDump($val, true)), LOG_DEBUG, __FILE__, __LINE__); } else { $app->logMsg(sprintf('Not setting session/database preference %s => %s', $key, getDump($val, true)), LOG_DEBUG, __FILE__, __LINE__); } break; case 'cookie': $name = $this->_getCookieName($key); $val = json_encode($val); $app->setCookie($name, $val, $this->getParam('cookie_expire'), $this->getParam('cookie_path'), $this->getParam('cookie_domain')); $_COOKIE[$name] = $val; $app->logMsg(sprintf('Setting cookie preference %s => %s', $key, $val), LOG_DEBUG, __FILE__, __LINE__); break; } } /** * Returns the value of the requested preference. Saved values take precedence, but if none is set * a default value is returned, or if not that, null. * * @param string $key The name of the preference to retrieve (or null to retrieve all keys). * @return string The value of the preference. */ public function get($key=null) { $app =& App::getInstance(); $this->_init(); switch ($this->getParam('storagetype')) { case 'session': case 'database': if (is_null($key) && isset($_SESSION['_prefs'][$this->_ns]['saved'])) { return $_SESSION['_prefs'][$this->_ns]['saved']; } if ($this->exists($key)) { $app->logMsg(sprintf('Found %s in saved', $key), LOG_DEBUG, __FILE__, __LINE__); return $_SESSION['_prefs'][$this->_ns]['saved'][$key]; } if (isset($_SESSION['_prefs'][$this->_ns]['defaults']) && array_key_exists($key, $_SESSION['_prefs'][$this->_ns]['defaults'])) { $app->logMsg(sprintf('Found %s in defaults', $key), LOG_DEBUG, __FILE__, __LINE__); return $_SESSION['_prefs'][$this->_ns]['defaults'][$key]; } $app->logMsg(sprintf('Key not found in prefs cache: %s', $key), LOG_DEBUG, __FILE__, __LINE__); return null; case 'cookie': if (is_null($key)) { $app->logMsg(sprintf('Unable to get(null) when using cookie storagetype.', null), LOG_WARNING, __FILE__, __LINE__); return null; } $name = $this->_getCookieName($key); if ($this->exists($key) && '' != $_COOKIE[$name]) { $val = json_decode($_COOKIE[$name], true); $app->logMsg(sprintf('Found %s in cookie: %s', $key, getDump($val)), LOG_DEBUG, __FILE__, __LINE__); return $val; } $app->logMsg(sprintf('Did not find %s in cookie', $key), LOG_DEBUG, __FILE__, __LINE__); return null; } } /** * To see if a preference has been set. * * @param string $key The name of the preference to check. * @return boolean True if the preference isset and not empty false otherwise. */ public function exists($key) { $this->_init(); switch ($this->getParam('storagetype')) { case 'session': case 'database': // isset() does not return TRUE for array keys that correspond to a NULL value, while array_key_exists() does. return (isset($_SESSION['_prefs'][$this->_ns]['saved']) && array_key_exists($key, $_SESSION['_prefs'][$this->_ns]['saved'])); case 'cookie': $name = $this->_getCookieName($key); return (isset($_COOKIE) && array_key_exists($name, $_COOKIE)); } } /** * Delete an existing preference value. This will also remove the value from the database, once save() is called. * * @param string $key The name of the preference to delete. */ public function delete($key) { $app =& App::getInstance(); $this->_init(); switch ($this->getParam('storagetype')) { case 'session': case 'database': unset($_SESSION['_prefs'][$this->_ns]['saved'][$key]); break; case 'cookie': if ($this->exists($key)) { // Just set the existing value to an empty string, which expires in the past. $name = $this->_getCookieName($key); $app->setCookie($name, '', time() - 86400); // Also unset the received cookie value, so it is unavailable. unset($_COOKIE[$name]); } break; } } /** * Resets all existing values under this namespace. This should be executed with the same consideration as $auth->clear(), such as when logging out. * Set $save true to persist the clear action to the storage (i.e., erase all stored prefs for this user). */ public function clear($scope='all', $save=false) { $app =& App::getInstance(); switch ($scope) { case 'all' : switch ($this->getParam('storagetype')) { case 'session': case 'database': $_SESSION['_prefs'][$this->_ns] = array( 'loaded' => false, 'load_datetime' => '1970-01-01', 'defaults' => array(), 'saved' => array(), ); break; case 'cookie': foreach ($_COOKIE as $key => $value) { // All cookie keys with our internal prefix. Use only the last part as the key. if (preg_match('/^' . preg_quote(sprintf('_prefs-%s-', $this->_ns)) . '(.+)$/i', $key, $match)) { $this->delete($match[1]); } } break; } break; case 'defaults' : $_SESSION['_prefs'][$this->_ns]['defaults'] = array(); break; case 'saved' : $_SESSION['_prefs'][$this->_ns]['saved'] = array(); break; } if ($save) { $this->save(true); $app->logMsg(sprintf('Deleted all %s %s prefs for user_id %s', $this->getParam('storagetype'), $this->_ns, $this->getParam('user_id')), LOG_INFO, __FILE__, __LINE__); } else { $app->logMsg(sprintf('Cleared %s %s prefs', $scope, $this->_ns), LOG_DEBUG, __FILE__, __LINE__); } } /* * Retrieves all prefs from the database and stores them in the $_SESSION. * * @access public * @param bool $force Set to always load from database, regardless if _isLoaded() or not. * @return bool True if loading succeeded. * @author Quinn Comendant * @version 1.0 * @since 04 Jun 2006 16:56:53 */ public function load($force=false) { $app =& App::getInstance(); $pdo =& \Strangecode\Codebase\PDO::getInstance(); // Skip this method if not using the db. if ('database' != $this->getParam('storagetype')) { $app->logMsg('Prefs->load() does nothing unless using a database storagetype.', LOG_DEBUG, __FILE__, __LINE__); return true; } $this->initDB(); $this->_init(); // Prefs already loaded for this session. if (!$force && $this->_isLoaded()) { return true; } // User_id must not be empty. if ('' == $this->getParam('user_id')) { $app->logMsg(sprintf('Cannot load prefs because user_id not set.', null), LOG_WARNING, __FILE__, __LINE__); return false; } // Clear existing cache. $this->clear('saved'); // Retrieve all prefs for this user and namespace. $stmt = $pdo->prepare(sprintf("SELECT `pref_key`, `pref_value` FROM `%s` WHERE `user_id` = ? AND `pref_namespace` = ? LIMIT 100000", $pdo->sanitizeIdentifier($this->getParam('db_table')))); $stmt->execute([$this->getParam('user_id'), $this->getParam('namespace')]); while (list($key, $val) = $stmt->fetch(\PDO::FETCH_NUM)) { $_SESSION['_prefs'][$this->_ns]['saved'][$key] = unserialize($val); } $app->logMsg(sprintf('Loaded %s prefs from database.', sizeof($_SESSION['_prefs'][$this->_ns]['saved'])), LOG_DEBUG, __FILE__, __LINE__); // Data loaded only once per session. $_SESSION['_prefs'][$this->_ns]['loaded'] = true; $_SESSION['_prefs'][$this->_ns]['load_datetime'] = date('Y-m-d H:i:s'); return true; } /* * Returns true if the prefs had been loaded from the database into the $_SESSION recently. * This function is simply a check so the database isn't access every page load. * * @access private * @return bool True if prefs are loaded. * @author Quinn Comendant * @version 1.0 * @since 04 Jun 2006 17:12:44 */ protected function _isLoaded() { if ('database' != $this->getParam('storagetype')) { $app->logMsg('Prefs->_isLoaded() does nothing unless using a database storagetype.', LOG_DEBUG, __FILE__, __LINE__); return true; } $this->_init(); if (isset($_SESSION['_prefs'][$this->_ns]['load_datetime']) && strtotime($_SESSION['_prefs'][$this->_ns]['load_datetime']) > time() - $this->getParam('load_timeout') && isset($_SESSION['_prefs'][$this->_ns]['loaded']) && true === $_SESSION['_prefs'][$this->_ns]['loaded']) { return true; } else { return false; } } /* * Saves all prefs stored in the $_SESSION into the database. * * @access public * @return bool True if prefs exist and were saved. * @author Quinn Comendant * @version 1.0 * @since 04 Jun 2006 17:19:56 */ public function save($allow_empty=false) { $app =& App::getInstance(); $pdo =& \Strangecode\Codebase\PDO::getInstance(); // Skip this method if not using the db. if ('database' != $this->getParam('storagetype')) { $app->logMsg('Prefs->save() does nothing unless using a database storagetype.', LOG_DEBUG, __FILE__, __LINE__); return true; } // User_id must not be empty. if ('' == $this->getParam('user_id')) { $app->logMsg(sprintf('Cannot save prefs because user_id not set.', null), LOG_DEBUG, __FILE__, __LINE__); return false; } $this->initDB(); $this->_init(); if (isset($_SESSION['_prefs'][$this->_ns]['saved']) && is_array($_SESSION['_prefs'][$this->_ns]['saved']) && ($allow_empty || !empty($_SESSION['_prefs'][$this->_ns]['saved']))) { // Delete old prefs from database. $stmt = $pdo->prepare(sprintf("DELETE FROM `%s` WHERE `user_id` = ? AND `pref_namespace` = ?", $pdo->sanitizeIdentifier($this->getParam('db_table')))); $stmt->execute([$this->getParam('user_id'), $this->getParam('namespace')]); // Insert new prefs. $insert_values = array(); foreach ($_SESSION['_prefs'][$this->_ns]['saved'] as $key => $val) { $insert_values[] = sprintf("(%s, %s, %s, %s)", $pdo->quote($this->getParam('user_id')), $pdo->quote($this->getParam('namespace')), $pdo->quote($key), $pdo->quote(serialize($val)) ); } if (!empty($insert_values)) { $stmt = $pdo->query(sprintf(" INSERT INTO `%s` (`user_id`, `pref_namespace`, `pref_key`, `pref_value`) VALUES %s ", $pdo->sanitizeIdentifier($this->getParam('db_table')), join(', ', $insert_values))); $app->logMsg(sprintf('Saved %s prefs to database for user_id %s.', sizeof($insert_values), $this->getParam('user_id')), LOG_DEBUG, __FILE__, __LINE__); } return true; } return false; } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 02 May 2014 18:17:04 */ protected function _getCookieName($key) { $app =& App::getInstance(); if (mb_strpos($key, sprintf('_prefs-%s', $this->_ns)) === 0) { $app->logMsg(sprintf('Invalid key name (%s). Leave off "_prefs-%s-" and it should work.', $key, $this->_ns), LOG_NOTICE, __FILE__, __LINE__); } // Use standardized class data names: _ + classname + namespace + variablekey // (namespace = namespace + user_id) return sprintf('_prefs-%s-%s', $this->_ns, $key); } }