* 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_scalar($key)) {
$app->logMsg(sprintf('Key is not a string-compatible type (%s)', getDump($key)), LOG_NOTICE, __FILE__, __LINE__);
return false;
}
$key = (string)$key;
if ('' === trim($key)) {
$app->logMsg(sprintf('Key is empty (val=%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);
}
}