<?php
/**
 * This file implements the Session class and holds the
 * {@link session_unserialize_callback()} function used by it.
 *
 * A session can be bound to a user and provides functions to store data in its
 * context.
 * All Hitlogs are also bound to a Session.
 *
 * This file is part of the evoCore framework - {@link http://evocore.net/}
 * See also {@link https://github.com/b2evolution/b2evolution}.
 *
 * @license GNU GPL v2 - {@link http://b2evolution.net/about/gnu-gpl-license}
 *
 * @copyright (c)2003-2020 by Francois Planque - {@link http://fplanque.com/}
 * Parts of this file are copyright (c)2004-2006 by Daniel HAHLER - {@link http://thequod.de/contact}.
 *
 * @package evocore
 */
if( !defined('EVO_MAIN_INIT') ) die( 'Please, do not access this page directly.' );


/**
 * A session tracks a given user (not necessarily logged in) while he's navigating the site.
 * A session also stores data for the length of the session.
 *
 * Sessions are tracked with a cookie containing the session ID.
 * The cookie also contains a random key to prevent sessions hacking.
 *
 * @package evocore
 */
class Session
{
	/**
	 * The ID of the session.
	 * @var integer
	 */
	var $ID;

	/**
	 * The session key (to be used in URLs).
	 * @var string
	 */
	var $key;

	/**
	 * The user ID for the user of the session (NULL for anonymous (not logged in) user).
	 *
	 * @var integer
	 */
	var $user_ID;

	/**
	 * Is the session validated?
	 * This means that it was created from a received cookie.
	 * @var boolean
	 */
	var $is_validated = false;

	/**
	 * Session start timestamp
	 * @var string
	 */
	var $start_ts;

	/**
	 * Session last seen timestamp which was logged
	 * Value may be off by up to 60 seconds
	 * @var string
	 */
	var $lastseen_ts;

	/**
	 * Data stored for the session.
	 *
	 * This holds an array( expire, value ) for each data item key.
	 *
	 * @access protected
	 * @var array
	 */
	var $_data;

	var $_session_needs_save = false;

	/**
	 * The user device from where this session was created
	 *
	 * @var string
	 */
	var $sess_device;

	/**
	 * Constructor
	 *
	 * If valid session cookie received or param session ID is provided: pull session from DB
	 * Otherwise, INSERT a session into DB
	 *
	 * @param integer Session ID
	 */
	function __construct( $session_ID = NULL )
	{
		global $DB, $Debuglog, $localtimenow, $Messages, $Settings, $UserSettings;
		global $Hit;
		global $cookie_session, $cookie_expires;

		$Debuglog->add( 'Session: cookie_domain='.get_cookie_domain(), 'request' );
		$Debuglog->add( 'Session: cookie_path='.get_cookie_path(), 'request' );

		$timeout_sessions = NULL;
		if( $this->user_ID != NULL )
		{	// User is not anonymous, get custom session timeout (may return NULL):
			$timeout_sessions = $UserSettings->get( 'timeout_sessions', $this->user_ID );
		}
		if( empty( $timeout_sessions ) )
		{	// User is anonymous or has no custom session timeout. So, we use default session timeout:
			$timeout_sessions = $Settings->get( 'timeout_sessions' );
		}

		if( $session_ID !== NULL )
		{	// Get Session by requested ID:
			$Debuglog->add( 'Session: ID='.$session_ID, 'request' );

			$session_SQL = new SQL( 'Get session data by ID#'.$session_ID );
			$session_SQL->SELECT( 'sess_ID, sess_key, sess_data, sess_user_ID, sess_start_ts, sess_lastseen_ts, sess_device' );
			$session_SQL->FROM( 'T_sessions' );
			$session_SQL->WHERE( 'sess_ID  = '.$DB->quote( $session_ID ) );
			$session_SQL->WHERE_and( 'UNIX_TIMESTAMP(sess_lastseen_ts) > '.( $localtimenow - $timeout_sessions ) );
			$row = $DB->get_row( $session_SQL );
			if( empty( $row ) )
			{
				$Debuglog->add( 'Session: Session by ID is invalid!', 'request' );
			}
		}
		else
		{	// Get Session from cookie data ID + key:
			$session_cookie = param_cookie( $cookie_session, 'string', '' );
			if( empty( $session_cookie ) )
			{
				$Debuglog->add( 'Session: No session cookie received.', 'request' );
			}
			elseif( ! preg_match( '~^(\d+)_(\w+)$~', $session_cookie, $match ) )
			{
				$Debuglog->add( 'Session: Invalid session cookie format!', 'request' );
			}
			else
			{	// We have a valid session cookie:
				$session_id_by_cookie = $match[1];
				$session_key_by_cookie = $match[2];

				$Debuglog->add( 'Session: Session ID received from cookie: '.$session_id_by_cookie, 'request' );

				$row = $DB->get_row( '
					SELECT sess_ID, sess_key, sess_data, sess_user_ID, sess_start_ts, sess_lastseen_ts, sess_device
					  FROM T_sessions
					 WHERE sess_ID  = '.$DB->quote( $session_id_by_cookie ).'
					   AND sess_key = '.$DB->quote( $session_key_by_cookie ).'
					   AND UNIX_TIMESTAMP(sess_lastseen_ts) > '.( $localtimenow - $timeout_sessions ) );
				if( empty( $row ) )
				{
					$Debuglog->add( 'Session: Session ID/key combination is invalid!', 'request' );
				}
			}
		}

				if( ! empty( $row ) )
				{	// Load session data if row is found in DB:
					$Debuglog->add( 'Session: Session ID is valid.', 'request' );
					$this->ID = $row->sess_ID;
					$this->key = $row->sess_key;
					$this->user_ID = $row->sess_user_ID;
					$this->start_ts = mysql2timestamp( $row->sess_start_ts );
					$this->lastseen_ts = mysql2timestamp( $row->sess_lastseen_ts );
					$this->is_validated = true;
					$this->sess_device = $row->sess_device;

					$Debuglog->add( 'Session: Session user_ID: '.var_export( $this->user_ID, true ), 'request' );

					if( empty( $row->sess_data ) )
					{
						$Debuglog->add( 'Session: No session data available.', 'request' );
						$this->_data = array();
					}
					else
					{ // Some session data has been previsouly stored:

						// Unserialize session data (using an own callback that should provide class definitions):
						$old_callback = @ini_set( 'unserialize_callback_func', 'session_unserialize_callback' );
						if( $old_callback === false || is_null( $old_callback ) /* disabled, reported with PHP 5.2.5 */ )
						{	// NULL if ini_set has been disabled for security reasons
							// Brutally load all classes that we might need:
 							session_unserialize_load_all_classes();
						}
						// TODO: dh> This can fail, if there are special chars in sess_data:
						//       It will be encoded in $evo_charset _after_ mysqli::set_charset but
						//       get retrieved here, _before_ any mysqli::set_charset (if $db_config['connection_charset'] is not set (default))!
						$this->_data = @unserialize( $row->sess_data );

						if( $old_callback !== false )
						{	// Restore the old callback if we changed it:
							@ini_set( 'unserialize_callback_func', $old_callback );
						}

						if( ! is_array( $this->_data ) )
						{
							$Debuglog->add( 'Session: Session data corrupted!<br />
								connection_charset: '.var_export( $DB->get_connection_charset(), true ).'<br />
								Serialized data was: --['.var_export( $row->sess_data, true ).']--', array('session','error') );
							$this->_data = array();
						}
						else
						{
							$Debuglog->add( 'Session: Session data loaded.', 'request' );

							// Load a Messages object from session data, if available:
							if( ( $sess_Messages = $this->get('Messages') ) && $sess_Messages instanceof Messages )
							{
								// dh> TODO: "old" messages should rather get prepended to any existing ones from the current request, rather than appended
								$Messages->add_messages( $sess_Messages );
								$Messages->affixed = $sess_Messages->affixed;
								$Debuglog->add( 'Session: Added Messages from session data.', 'request' );
								$this->delete( 'Messages' );
							}
						}
					}
				}


		if( $this->ID )
		{ // there was a valid session before
			if( $this->lastseen_ts < $localtimenow - 60 )
			{ // lastseen timestamp is older then a minute, it needs to be updated at page exit
				$this->session_needs_save( true );
			}
		}
		elseif( $session_ID === NULL )
		{	// Create a new session! Only for session from cookie:
			// Do NOT create new session on request by ID!
			$this->key = generate_random_key( 32 );

			// Detect user device
			global $user_devices;
			$this->sess_device = '';

			if( ! empty( $_SERVER['HTTP_USER_AGENT'] ) )
			{
				foreach( $user_devices as $device_name => $device_regexp )
				{
					if( preg_match( '~'.$device_regexp.'~i', $_SERVER['HTTP_USER_AGENT'] ) )
					{
						$this->sess_device = $device_name;
						break;
					}
				}
			}

			// We need to INSERT now because we need an ID now! (for the cookie)
			$DB->query( 'INSERT INTO T_sessions( sess_key, sess_start_ts, sess_lastseen_ts, sess_ipaddress, sess_device )
				VALUES (
					'.$DB->quote( $this->key ).',
					FROM_UNIXTIME('.$localtimenow.'),
					FROM_UNIXTIME('.$localtimenow.'),
					'.$DB->quote( $Hit->IP ).',
					'.$DB->quote( $this->sess_device ).'
				)' );

			$this->ID = $DB->insert_id;

			// Set a cookie valid for ~ 10 years:
			evo_setcookie( $cookie_session, $this->ID.'_'.$this->key, time()+315360000, '', '', false, true );

			$Debuglog->add( 'Session: ID (generated): '.$this->ID, 'request' );
			$Debuglog->add( 'Session: Cookie sent.', 'request' );
		}
	}


	/**
	 * Get this class db table config params
	 *
	 * @return array
	 */
	static function get_class_db_config()
	{
		static $session_db_config;

		if( !isset( $session_db_config ) )
		{
			$session_db_config = array(
				'dbtablename'        => 'T_sessions',
				'dbprefix'           => 'sess_',
				'dbIDname'           => 'sess_ID',
			);
		}

		return $session_db_config;
	}


	/**
	 * Delete sessions from database based on where condition or by object ids
	 *
	 * @param string where condition
	 * @param array object ids
	 * @param array additional params if required
	 * @return mixed # of rows affected or false if error
	 */
	static function db_delete_where( $sql_where, $object_ids = NULL, $params = NULL )
	{
		global $DB;

		$DB->begin();

		if( ! empty( $sql_where ) )
		{
			$object_ids = $DB->get_col( 'SELECT sess_ID FROM T_sessions WHERE '.$sql_where );
		}

		if( ! $object_ids )
		{ // There is no session to delete
			$DB->commit();
			return;
		}

		$session_ids_to_delete = implode( ', ', $object_ids );

		$result = $DB->query( 'DELETE FROM T_hitlog WHERE hit_sess_ID IN ( '.$session_ids_to_delete.' )' );

		$result = ( $result !== false ) ? $DB->query( 'DELETE FROM T_sessions WHERE sess_ID IN ( '.$session_ids_to_delete.')' ) : $result;

		( $result !== false ) ? $DB->commit() : $DB->rollback();

		return $result;
	}


	function session_needs_save( $session_needs_save )
	{
		// pre_dump( 'SETTING session needs save to', $session_needs_save );
		$this->_session_needs_save = $session_needs_save;
	}

	/**
	 * Attach a User object to the session.
	 *
	 * @param User The user to attach
	 */
	function set_User( $User )
	{
		return $this->set_user_ID( $User->ID );
	}


	/**
	 * Attach a user ID to the session.
	 *
	 * NOTE: ID gets saved to DB on shutdown. This may be a "problem" when querying T_sessions for sess_user_ID.
	 *
	 * @param integer The ID of the user to attach
	 */
	function set_user_ID( $user_ID )
	{
		if( $user_ID != $this->user_ID )
		{
			global $Settings, $UserSettings, $DB;

			$multiple_sessions = $Settings->get( 'multiple_sessions' );

			if( $multiple_sessions != 'always' && ( $multiple_sessions == 'never' || !$UserSettings->get('login_multiple_sessions', $user_ID) ) )
			{ // The user does not want/is not allowed to have multiple sessions open at the same time:
				// Invalidate previous sessions:
				global $Debuglog;
				$Debuglog->add( 'Session: Invalidating all previous user sessions, because login_multiple_sessions=0', 'request' );
				$DB->query( '
					UPDATE T_sessions
					   SET sess_key = NULL
					 WHERE sess_user_ID = '.$DB->quote( $user_ID ).'
					   AND sess_ID != '.$this->ID );
			}

			$this->user_ID = $user_ID;
			$this->session_needs_save( true );
		}
	}


	/**
	 * Logout the user, by invalidating the session key and unsetting {@link $user_ID}.
	 *
	 * We want to keep the user in the session log, but we're unsetting {@link $user_ID}, which refers
	 * to the current session.
	 *
	 * Because the session key is invalid/broken, on the next request a new session will be started.
	 *
	 * NOTE: we MIGHT want to link subsequent sessions together if we want to keep track...
	 */
	function logout()
	{
		global $Debuglog, $cookie_session, $Settings;

		if( $Settings->get( 'http_auth_accept' ) && isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) )
		{	// Don't log out when user is logged in by HTTP basic authentication because it is possible only after browser closing:
			// (to avoid infinite redirection after logout to $baseurl)
			return;
		}

		// Invalidate the session key (no one will be able to use this session again)
		$this->key = NULL;
		$this->_data = array(); // We don't need to keep old data
		$this->session_needs_save( true );
		$this->dbsave();

		$this->user_ID = NULL; // Unset user_ID after invalidating/saving the session above, to keep the user info attached to the old session.

		// clean up the session cookie:
		evo_setcookie( $cookie_session, '', 200000000, '', '', false, true );
	}


	/**
	 * Check if session has a user attached.
	 *
	 * @return boolean
	 */
	function has_User()
	{
		return !empty( $this->user_ID );
	}


	/**
	 * Get the attached User.
	 *
	 * @return false|User
	 */
	function & get_User()
	{
		if( ! empty( $this->user_ID ) )
		{
			$UserCache = & get_UserCache();
			return $UserCache->get_by_ID( $this->user_ID );
		}

		$r = false;
		return $r;
	}


	/**
	 * Get a data value for the session. This checks for the data to be expired and unsets it then.
	 *
	 * @param string Name of the data's key.
	 * @param mixed Default value to use if key is not set or has expired. (since 1.10.0)
	 * @return mixed The value, if set; otherwise $default
	 */
	function get( $param, $default = NULL )
	{
		global $Debuglog, $localtimenow;

		if( isset( $this->_data[$param] ) )
		{
			if( array_key_exists( 1, $this->_data[$param] ) // can be NULL!
			  && ( is_null( $this->_data[$param][0] ) || $this->_data[$param][0] > $localtimenow ) ) // check for expired data
			{
				return $this->_data[$param][1];
			}
			else
			{ // expired or old format (without 'value' key)
				unset( $this->_data[$param] );
				$this->session_needs_save( true );
				$Debuglog->add( 'Session: Session data['.$param.'] expired.', 'request' );
			}
		}

		return $default;
	}


	/**
	 * Set a data value for the session.
	 *
	 * Updated values get saved to the DB automatically on shutdown, in {@link shutdown()}.
	 *
	 * @param string Name of the data's key.
	 * @param mixed The value
	 * @param integer Time in seconds for data to expire (0 to disable).
	 */
	function set( $param, $value, $expire = 0 )
	{
		global $Debuglog, $localtimenow;

		if( ! isset( $this->_data[$param] )
		 || ! is_array( $this->_data[$param] ) // deprecated: check to transform 1.6 session data to 1.7
		 || $this->_data[$param][1] != $value
		 || $expire != 0 )
		{	// There is something to update:
			if( isset( $this->_data[ $param ] ) )
			{	// Log updated session var:
				if( isset( $this->_data[ $param ][1] ) )
				{
					if( is_scalar( $this->_data[ $param ][1] ) )
					{	// Display value for scalar var:
						$session_old_value = '<code class="debug_code_inline">'.$this->_data[ $param ][1].'</code>';
					}
					elseif( is_array( $this->_data[ $param ][1] ) )
					{	// Display only type for array var:
						$session_old_value = 'Array( '.implode( ', ', array_keys( $this->_data[ $param ][1] ) ).' )';
					}
					elseif( is_object( $this->_data[ $param ][1] ) )
					{	// Display class name for object var:
						$session_old_value = 'Object: '.get_class( $this->_data[ $param ][1] );
					}
					else
					{	// Display var type for others:
						$session_old_value = gettype( $this->_data[ $param ][1] );
					}
				}
				else
				{	// No old value:
					$session_old_value = '';
				}
				$action_type = 'updated (old value: '.$session_old_value.')';
			}
			else
			{	// Log created session var:
				$action_type = 'created';
			}
			$this->_data[$param] = array( ( $expire ? ( $localtimenow + $expire ) : NULL ), $value );

			if( $param == 'Messages' )
			{ // also set boolean to not call CachePageContent plugin event on next request:
				$this->set( 'core.no_CachePageContent', 1 );
			}

			$Debuglog->add( 'Session: Session data['.$param.'] '.$action_type.'. Expire in: '.( $expire ? $expire.'s' : 'never' ).'.', 'request' );

			$this->session_needs_save( true );
		}
	}


	/**
	 * Delete a value from the session data.
	 *
	 * @param string Name of the data's key.
	 */
	function delete( $param )
	{
		global $Debuglog;

		if( isset( $this->_data[$param] ) )
		{
			unset( $this->_data[$param] );

			$Debuglog->add( 'Session: Session data['.$param.'] deleted!', 'request' );

			$this->session_needs_save( true );
		}
	}


	/**
	 * Updates session data in database.
	 *
	 * NOTE: Debuglog additions will may not be displayed since the debuglog may alreayd have been displayed (shutdown function)
	 */
	function dbsave()
	{
		global $DB, $Debuglog, $Hit, $localtimenow;

		if( ! $this->_session_needs_save )
		{	// There have been no changes since the last save.
			$Debuglog->add( 'Session: Session is up to date and does not need to be saved.', 'request' );
			return false;
		}

		$sess_data = empty( $this->_data ) ? NULL : serialize( $this->_data );

	 	// Note: The key actually only needs to be updated on a logout.
	 	// Note: we increase the hitcoutn every time. That assumes that there will be no 2 calls for a single hit.
	 	//       Anyway it is not a big problem if this number is approximate.
		$sql = "UPDATE T_sessions SET
				sess_lastseen_ts = FROM_UNIXTIME(".$localtimenow."),
				sess_data = ".$DB->quote( $sess_data ).",
				sess_ipaddress = '".$Hit->IP."',
				sess_key = ".$DB->quote( $this->key );
		if( ! is_null( $this->user_ID ) )
		{	// We do NOT erase existing IDs at logout. We only want to set IDs at login:
				$sql .= ", sess_user_ID = ".$this->user_ID;
		}
		$sql .= "	WHERE sess_ID = ".$this->ID;

		$DB->query( $sql, 'Session::dbsave()' );

		$Debuglog->add( 'Session: Session data saved!', 'request' );

		$this->session_needs_save( false );
	}


	/**
	 * Reload session data.
	 *
	 * This is needed if the running process waits for a child process to write data
	 * into the Session, e.g. the captcha plugin in test mode waiting for the Debuglog
	 * output from the process that created the image (included through an IMG tag).
	 */
	function reload_data()
	{
		global $Debuglog, $DB;

		if( empty( $this->ID ) )
		{
			return false;
		}

		$sess_data = $DB->get_var( '
			SELECT SQL_NO_CACHE sess_data FROM T_sessions
			 WHERE sess_ID = '.$this->ID );

		$sess_data = @unserialize( $sess_data );
		if( $sess_data === false )
		{
			$this->_data = array();
		}
		else
		{
			$this->_data = $sess_data;
		}

		$Debuglog->add( 'Session: Reloaded session data.' );
	}


	/**
	 * Create a crumb that will be saved into the Session and returned to the caller for inclusion in Form or action url.
	 *
	 * For any action, a new crumb is generated every hour and the previous one is saved. (2 hours are valid)
	 *
	 * @param string crumb name
	 * @return string crumb value
	 */
	function create_crumb( $crumb_name )
	{
		global $servertimenow, $crumb_expires, $Debuglog;

		// Retrieve latest saved crumb:
		$crumb_recalled = $this->get( 'crumb_latest_'.$crumb_name, '-0' );
		list( $crumb_value, $crumb_time ) = explode( '-', $crumb_recalled );

		if( $servertimenow - $crumb_time > ( $crumb_expires / 2 ) )
		{	// The crumb we already had is older than 1 hour...
			// We'll need to generate a new value:
			$crumb_value = '';
			if( $servertimenow - $crumb_time < ( $crumb_expires - 200 ) ) // Leave some margin here to make sure we do no overwrite a newer 1-2 hr crumb
			{	// Not too old either, save as previous crumb:
				$Debuglog->add( 'Session: Store previous value <code class="debug_code_inline">'.$crumb_value.'-'.$servertimenow.'</code> for crumb ['.$crumb_name.'].', 'request' );

				$this->set( 'crumb_prev_'.$crumb_name, $crumb_recalled );
			}
		}

		if( empty( $crumb_value ) )
		{	// We need to generate a new crumb:
			$crumb_value = generate_random_key( 32 );

			$Debuglog->add( 'Session: Add new crumb ['.$crumb_name.'] = <code class="debug_code_inline">'.$crumb_value.'-'.$servertimenow.'</code> (Store the latest value).', 'request' );

			// Save crumb into session so we can later compare it to what get got back from the user request:
			$this->set( 'crumb_latest_'.$crumb_name, $crumb_value.'-'.$servertimenow );
		}
		return $crumb_value;
	}


	/**
	 * Assert that we received a valid crumb for the object we want to act on.
	 *
	 * This will DIE if we have not received a valid crumb.
	 *
	 * The received crumb must match a crumb we previously saved less than 2 hours ago.
	 *
	 * @param string crumb name
	 * @param boolean true if the script should die on error
	 * @param array Additional parameters
	 */
	function assert_received_crumb( $crumb_name, $die = true, $params = array() )
	{
		global $servertimenow, $crumb_expires, $debug;

		$params = array_merge( array(
				'msg_format'        => 'html',
				'msg_no_crumb'      => 'Missing crumb ['.$crumb_name.'] -- It looks like this request is not legit.',
				'msg_incorrect_crumb' => T_('Incorrect crumb received!'),
				'msg_expired_crumb'   => T_('Expired crumb received!'),
				'error_code'        => '400 Bad Crumb Request',
			), $params );

		if( ! $crumb_received = param( 'crumb_'.$crumb_name, 'string', NULL ) )
		{ // We did not receive a crumb!
			if( $die )
			{
				// Add message to AJAX Log:
				ajax_log_add( $params['msg_no_crumb'].' ['.$crumb_name.']', 'error' );
				// Die with error message:
				bad_request_die( $params['msg_no_crumb'], $params['error_code'], $params['msg_format'] );
			}
			return false;
		}

		// Retrieve latest and previous crumbs:
		list( $crumb_latest, $latest_crumb_time ) = explode( '-', $this->get( 'crumb_latest_'.$crumb_name, '-0' ) );
		list( $crumb_previous, $prev_crumb_time ) = explode( '-', $this->get( 'crumb_prev_'.$crumb_name, '-0' ) );

		// Validate the latest and previous crumbs:
		$crumb_latest_is_valid = ( $crumb_received == $crumb_latest );
		$crumb_previous_is_valid = ( $crumb_received == $crumb_previous );

		if( $crumb_latest_is_valid || $crumb_previous_is_valid )
		{	// At least one crumb is valid, check for expired:
			if( $crumb_latest_is_valid && $servertimenow - $latest_crumb_time <= $crumb_expires )
			{	// The latest crumb is valid and not expired:
				return true;
			}
			if( $crumb_previous_is_valid && $servertimenow - $prev_crumb_time <= $crumb_expires )
			{	// The previous crumb is valid and not expired:
				return true;
			}
			// The latest and previous crumbs are expired:
			$crumb_info_message = $params['msg_expired_crumb'];
			$crumb_latest_status = ( $crumb_latest_is_valid ? 'EXPIRED' : 'Invalid' );
			$crumb_previous_status = ( $crumb_previous_is_valid ? 'EXPIRED' : 'Invalid' );
		}
		else
		{	// The latest and previous crumbs are invalid:
			$crumb_info_message = $params['msg_incorrect_crumb'];
			$crumb_latest_status = 'Invalid';
			$crumb_previous_status = 'Invalid';
		}

		if( ! $die )
		{	// Return false result when we don't need die action on error:
			return false;
		}

		// Attempt to output an error header (will not work if the output buffer has already flushed once):
		// This should help preventing indexing robots from indexing the error :P
		if( ! headers_sent() )
		{
			load_funcs( '_core/_template.funcs.php' );
			headers_content_mightcache( 'text/html', 0, '#', false ); // Do NOT cache error messages! (Users would not see they fixed them)
			header_http_response( $params['error_code'] );
		}

		$crumb_info_full_message = $crumb_info_message.' '.sprintf( T_('Have you waited more than %d minutes before submitting your request?'), floor( $crumb_expires / 60 ) );

		// Info for debug log:
		$received_crumb_info = 'Received crumb: '.$crumb_received;
		$latest_crumb_info = 'Latest saved crumb: '.( empty( $crumb_latest ) && empty( $latest_crumb_time )
			? 'Not saved yet' // The latest crumb was not saved in Sessions yet.
			: $crumb_latest.' (Created at: '.date( 'Y-m-d H:i:s', $latest_crumb_time ).' - '.$crumb_latest_status.')' );
		$previous_crumb_info = 'Previous saved crumb: '.( empty( $crumb_previous ) && empty( $prev_crumb_time )
			? 'Not saved yet' // Previous crumb was not saved in Sessions yet.
			: $crumb_previous.' (Created at: '.date( 'Y-m-d H:i:s', $prev_crumb_time ).' - '.$crumb_previous_status.')' );
		$session_info = 'Session ID: '.$this->ID;

		// Add messages to AJAX Log:
		ajax_log_add( $crumb_info_full_message.' (Config var: <code class="debug_code_inline">$crumb_expires</code> = '.$crumb_expires.' seconds)', 'error' );
		ajax_log_add( $received_crumb_info, 'error' );
		ajax_log_add( $latest_crumb_info, 'error' );
		ajax_log_add( $previous_crumb_info, 'error' );
		ajax_log_add( $session_info, 'error' );

		if( $params['msg_format'] == 'text' )
		{	// Display error message in TEXT format:
			echo $crumb_info_full_message;
			// Display AJAX Log if it was initialized:
			ajax_log_display();
			die();
		}
		// else display error message in HTML format:

		// ERROR MESSAGE, with form/button to bypass and enough warning hopefully.
		// TODO: dh> please review carefully!
		global $io_charset;
		// erhsatingin > Most browsers will output this is in the <head> section. Either this or possibility of multiple <head> declarations.
		// echo '<head>';
		echo '<meta http-equiv="Content-Type" content="text/html; charset='.$io_charset.'" />';
		// echo '</head>';

		echo '<div style="background-color: #fdd; padding: 1ex; margin-bottom: 1ex;">';
		echo '<h3 style="color:#f00;">'.$crumb_info_message.' ['.$crumb_name.']</h3>';
		echo '<p>'.T_('Your request was stopped for security reasons.').'</p>';
		echo '<p>'.$crumb_info_full_message.'</p>';
		echo '<p>'.T_('Please go back to the previous page and refresh it before submitting the form again.').'</p>';
		echo '</div>';

		if( $debug > 0 )
		{	// Display additional info when debug is enabled:
			echo '<div>';
			echo '<p>'.$received_crumb_info.'</p>';
			echo '<p>'.$latest_crumb_info.'</p>';
			echo '<p>'.$previous_crumb_info.'</p>';
			echo '<p>'.$session_info.'</p>';
			echo '</div>';
		}

		echo '<div>';
		echo '<p class="warning">'.T_('Alternatively, you can try to resubmit your request with a refreshed crumb:').'</p>';
		$Form = new Form( '', 'evo_session_crumb_resend', $_SERVER['REQUEST_METHOD'] );
		$Form->begin_form( 'inline' );
		$Form->add_crumb( $crumb_name );
		$Form->hiddens_by_key( $_REQUEST );
		$Form->button( array( 'submit', '', T_('Resubmit now!'), 'ActionButton' ) );
		$Form->end_form();
		echo '</div>';

		// Display AJAX Log if it was initialized:
		ajax_log_display();

		die();
	}


	/**
	 * Was this session created from a mobile device
	 */
	function is_mobile_session()
	{
		global $mobile_user_devices;

		return array_key_exists( $this->sess_device, $mobile_user_devices );
	}


	/**
	 * Was this session created from a mobile device
	 */
	function is_tablet_session()
	{
		global $tablet_user_devices;

		return array_key_exists( $this->sess_device, $tablet_user_devices );
	}


	/**
	 * Was this session specially switched to alternative skin
	 */
	function is_alt_session()
	{
		global $Blog, $Hit;

		if( $this->get( 'using_alt_skin' ) )
		{	// Do additional check to know if we can continue to use Alt skin:
			if( isset( $Blog ) &&
			    strpos( $Hit->get_referer(), $Blog->get( 'url' ) ) === 0 )
			{	// We can continue to use Alt skin because referer URL is started with collection base URL:
				return true;
			}
			else
			{	// Don't continue to use Alt skin because referer is not started with collection base URL:
				$this->delete( 'using_alt_skin' );
			}
		}

		if( isset( $Hit ) &&
		    isset( $Blog ) &&
		    $Blog->get_setting( 'display_alt_skin_referer' ) &&
		    strpos( $Hit->get_referer(), $Blog->get_setting( 'display_alt_skin_referer_url' ) ) !== false )
		{	// Current referer URL contains string from collection setting:
			$this->set( 'using_alt_skin', true );
			return true;
		}

		// No condition is found to display Alt skin:
		return false;
	}


	/**
	 * Was this session created from a desktop device
	 */
	function is_desktop_session()
	{
		global $pc_user_devices;

		return array_key_exists( $this->sess_device, $pc_user_devices );
	}


	/**
	 * Get first hit params of this session
	 *
	 * @return object Params, array, keys are names of T_hitlog fields: hit_ID, hit_sess_ID, hit_uri, hit_referer, hit_referer_dom_ID and etc.
	 */
	function get_first_hit_params()
	{
		if( ! isset( $this->first_hit_params ) )
		{	// Get the params from DB only first time and then cache them:
			global $DB;

			$SQL = new SQL( 'Get first hit params of the session #'.$this->ID );
			$SQL->SELECT( '*' );
			$SQL->FROM( 'T_hitlog' );
			$SQL->WHERE( 'hit_sess_ID = '.$DB->quote( $this->ID ) );
			$SQL->ORDER_BY( 'hit_ID ASC' );
			$SQL->LIMIT( '1' );

			$this->first_hit_params = $DB->get_row( $SQL );
		}

		return $this->first_hit_params;
	}
}


/**
 * This gets used as a {@link unserialize()} callback function, which is
 * responsible for loading the requested class.
 *
 * IMPORTANT: when modifying this, modify the following also:
 * @see session_unserialize_load_all_classes()
 *
 * @todo Once we require PHP5, we should think about using this as __autoload function.
 *
 * @return boolean True, if the required class could be loaded; false, if not
 */
function session_unserialize_callback( $classname )
{
	switch( strtolower( $classname ) )
	{
		case 'blog':
			load_class( 'collections/model/_blog.class.php', 'Blog' );
			return true;

		case 'collectionsettings':
			load_class( 'collections/model/_collsettings.class.php', 'CollectionSettings' );
			return true;

		case 'comment':
			load_class( 'comments/model/_comment.class.php', 'Comment' );
			return true;

		case 'item':
			load_class( 'items/model/_item.class.php', 'Item' );
			return true;

		case 'itemsettings':
			load_class( 'items/model/_itemsettings.class.php', 'ItemSettings' );
			return true;

		case 'group':
			load_class( 'users/model/_group.class.php', 'Group' );
			return true;

		case 'user':
			load_class( 'users/model/_user.class.php', 'User' );
			return true;
	}

	return false;
}


/**
 * When session_unserialize_callback() cannot be registered to do some smart loading,
 * then we fall back to this function and load everything with brute force...
 *
 * IMPORTANT: when modifying this, modify the following also:
 * @see session_unserialize_callback()
 */
function session_unserialize_load_all_classes()
{
	load_class( 'collections/model/_blog.class.php', 'Blog' );
	load_class( 'collections/model/_collsettings.class.php', 'CollectionSettings' );
	load_class( 'comments/model/_comment.class.php', 'Comment' );
	load_class( 'items/model/_item.class.php', 'Item' );
	load_class( 'items/model/_itemsettings.class.php', 'ItemSettings' );
	load_class( 'users/model/_group.class.php', 'Group' );
	load_class( 'users/model/_user.class.php', 'User' );
}

?>