<?php
/**
 * @package Habari
 *
 */

/**
 * Habari Session class
 *
 * Manages sessions for the PHP session routines
 *
 */
class Session
{
	/*
	 * The initial data. Used to determine whether we should write anything.
	 */
	private static $initial_data;
	private static $lifetime;


	/**
	 * Initialize the session handlers
	 */
	public static function init()
	{
		
		// the default path for the session cookie is /, but let's make that potentially more restrictive so no one steals our cookehs
		// we also can't use 'null' when we set a secure-only value, because that doesn't mean the same as the default like it should
		$path = Site::get_path( 'base', true );
		
		// the default is not to require a secure session
		$secure = false;
		
		// if we want to always require secure
		if ( Config::get( 'force_secure_session' ) == true ) {
			$secure = true;
		}
		
		// if this is an HTTPS connection by default we will
		// IIS sets HTTPS == 'off', so we have to check the value too
		if ( isset( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] == 'on' ) {
			$secure = true;
		}
		
		// but if we have explicitly disabled it, don't
		// note the ===. not setting it (ie: null) should not be the same as setting it to false
		if ( Config::get( 'force_secure_session' ) === false ) {
			$secure = false;
		}
		
		// now we've got a path and secure, so set the cookie values
		session_set_cookie_params( null, $path, null, $secure );
		
		// figure out the session lifetime and let plugins change it
		$lifetime = ini_get( 'session.gc_maxlifetime' );
		
		self::$lifetime = Plugins::filter( 'session_lifetime', $lifetime );
		

		session_set_save_handler(
			array( 'Session', 'open' ),
			array( 'Session', 'close' ),
			array( 'Session', 'read' ),
			array( 'Session', 'write' ),
			array( 'Session', 'destroy' ),
			array( 'Session', 'gc' )
		);
		// session::write gets called after object destruction, so our class isn't available
		// fix that by registering it as a shutdown function, before objects are destroyed
		register_shutdown_function( 'session_write_close' );

		if ( ! isset( $_SESSION ) ) {
			session_start();
		}
		return true;
	}

	/**
	 * Executed when opening a session.
	 * Not useful for Habari
	 */
	public static function open( $save_path, $session_name )
	{
		// Does this function need to do anything?
		return true;
	}

	/**
	 * Executed when closing a session.
	 * Not useful for Habari
	 */
	public static function close()
	{
		// Does this function need to do anything?
		return true;
	}

	/**
	 * Read session data from the database to return into the $_SESSION global.
	 * Verifies against a number of parameters for security purposes.
	 *
	 * @param string $session_id The id generated by PHP for the session.
	 * @return string The retrieved session.
	 */
	public static function read( $session_id )
	{
		$remote_address = Utils::get_ip();
		// not always set, even by real browsers
		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
		$session = DB::get_row( 'SELECT * FROM {sessions} WHERE token = ?', array( $session_id ) );

		// Verify session exists
		if ( !$session ) {
			self::$initial_data = false;
			return false;
		}

		$dodelete = false;

		if ( !defined( 'SESSION_SKIP_SUBNET' ) || SESSION_SKIP_SUBNET != true ) {
			// Verify on the same subnet
			$subnet = self::get_subnet( $remote_address );
			if ( $session->ip != $subnet ) {
				$dodelete = true;
			}
		}

		// Verify expiry
		if ( HabariDateTime::date_create()->int > $session->expires ) {
			Session::error( _t( 'Your session expired.' ), 'expired_session' );
			$dodelete = true;
		}

		// Verify User Agent
		if ( $user_agent != $session->ua ) {
			$dodelete = true;
		}

		// Let plugins ultimately decide
		$dodelete = Plugins::filter( 'session_read', $dodelete, $session, $session_id );

		if ( $dodelete ) {
			$sql = 'DELETE FROM {sessions} WHERE token = ?';
			$args = array( $session_id );
			$sql = Plugins::filter( 'sessions_clean', $sql, 'read', $args );
			DB::query( $sql, $args );
			return false;
		}

		// Do garbage collection, since PHP is bad at it
		$probability = ini_get( 'session.gc_probability' );
		// Allow plugins to control the probability of a gc event, return >=100 to always collect garbage
		$probability = Plugins::filter( 'session_gc_probability', ( is_numeric( $probability ) && $probability > 0 ) ? $probability : 1 );
		if ( rand( 1, 100 ) <= $probability ) {
			self::gc( self::$lifetime );
		}

		// save the initial data we loaded so we can write only if it's changed
		self::$initial_data = $session->data;
		
		// but if the expiration is close (less than half the session lifetime away), null it out so the session always gets written so we extend the session
		if ( ( $session->expires - HabariDateTime::date_create()->int ) < ( self::$lifetime / 2 ) ) {
			self::$initial_data = null;
		}

		return $session->data;
	}

	/**
	 * Commit $_SESSION data to the database for this user.
	 *
	 * @param string $session_id The PHP-generated session id
	 * @param string $data Data from session stored as a string
	 */
	public static function write( $session_id, $data )
	{

		$remote_address = Utils::get_ip();
		// not always set, even by real browsers
		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';

		// default to writing the data only if it's changed
		if ( self::$initial_data !== $data ) {
			$dowrite = true;
		}
		else {
			$dowrite = false;
		}

		// but let a plugin make the final decision. we may want to ignore search spiders, for instance
		$dowrite = Plugins::filter( 'session_write', $dowrite, $session_id, $data );

		if ( $dowrite ) {
			// DB::update() checks if the record key exists, and inserts if not
			$record = array(
				'ip' => self::get_subnet( $remote_address ),
				'expires' => HabariDateTime::date_create()->int + self::$lifetime,
				'ua' => $user_agent,
				'data' => $data,
			);
			DB::update(
				DB::table( 'sessions' ),
				$record,
				array( 'token' => $session_id )
			);
		}
	}

	/**
	 * Destroy stored session data by session id
	 *
	 * @param string $session_id The PHP generated session id
	 * @return
	 */
	public static function destroy( $session_id )
	{
		$sql = 'DELETE FROM {sessions} WHERE token = ?';
		$args = array( $session_id );
		$sql = Plugins::filter( 'sessions_clean', $sql, 'destroy', $args );
		DB::query( $sql, $args );
		return true;
	}

	/**
	 * Session garbage collection deletes expired sessions
	 *
	 * @param mixed $max_lifetime Unused - The session expiration time, in seconds.
	 */
	public static function gc( $max_lifetime )
	{
		$sql = 'DELETE FROM {sessions} WHERE expires < ?';
		$args = array( HabariDateTime::date_create()->int );
		$sql = Plugins::filter( 'sessions_clean', $sql, 'gc', $args );
		DB::query( $sql, $args );
		return true;
	}

	/**
	 * Sets the user_id attached to the current session
	 *
	 * @param integer $user_id The user id of the current user
	 */
	public static function set_userid( $user_id )
	{
		DB::query( 'UPDATE {sessions} SET user_id = ? WHERE token = ?', array( $user_id, session_id() ) );
	}


	/**
	 * Clear the user_id attached to sessions, delete other sessions that are associated to the user_id
	 * @param integer $user_id The user_id to clear.
	 */
	public static function clear_userid( $user_id )
	{
		DB::query( 'DELETE FROM {sessions} WHERE user_id = ? AND token <> ?', array( $user_id, session_id() ) );
		DB::query( 'UPDATE {sessions} SET user_id = NULL WHERE token = ?', array( session_id() ) );
	}

	/**
	 * Adds a value to a session set
	 *
	 * @param string $set Name of the set
	 * @param mixed $value value to store
	 * @param string $key Optional unique key for the set under which to store the value
	 */
	public static function add_to_set( $set, $value, $key = null )
	{
		if ( !isset( $_SESSION[$set] ) ) {
			$_SESSION[$set] = array();
		}
		if ( $key ) {
			$_SESSION[$set][$key] = $value;
		}
		else {
			$_SESSION[$set][] = $value;
		}
	}

	/**
	 * Store a notice message in the user's session
	 *
	 * @param string $notice The notice message
	 * @param string $key An optional id that would guarantee a single unique message for this key
	 */
	public static function notice( $notice, $key = null )
	{
		self::add_to_set( 'notices', $notice, $key );
	}

	/**
	 * Store an error message in the user's session
	 *
	 * @param string $error The error message
	 * @param string $key An optional id that would guarantee a single unique message for this key
	 */
	public static function error( $error, $key = null )
	{
		self::add_to_set( 'errors', $error, $key );
	}

	/**
	 * Return a set of messages
	 *
	 * @param string $set The name of the message set
	 * @param boolean $clear true to clear the messages from the session upon receipt
	 * @return array An array of message strings
	 */
	public static function get_set( $set, $clear = true )
	{
		if ( !isset( $_SESSION[$set] ) ) {
			$set_array = array();
		}
		else {
			$set_array = $_SESSION[$set];
		}
		if ( $clear ) {
			unset( $_SESSION[$set] );
		}
		return $set_array;
	}

	/**
	 * Get all notice messsages from the user session
	 *
	 * @param boolean $clear true to clear the messages from the session upon receipt
	 * @return array And array of notice messages
	 */
	public static function get_notices( $clear = true )
	{
		return self::get_set( 'notices', $clear );
	}

	/**
	 * Retrieve a specific notice from stored errors.
	 *
	 * @param string $key ID of the notice to retrieve
	 * @param boolean $clear true to clear the notice from the session upon receipt
	 * @return string Return the notice message
	 */
	public static function get_notice( $key, $clear = true )
	{
		$notices = self::get_notices( false );
		if ( isset( $notices[$key] ) ) {
			$notice = $notices[$key];
			if ( $clear ) {
				self::remove_notice( $key );
			}
			return $notice;
		}
	}

	/**
	 * Get all error messsages from the user session
	 *
	 * @param boolean $clear true to clear the messages from the session upon receipt
	 * @return array And array of error messages
	 */
	public static function get_errors( $clear = true )
	{
		return self::get_set( 'errors', $clear );
	}

	/**
	 * Retrieve a specific error from stored errors.
	 *
	 * @param string $key ID of the error to retrieve
	 * @param boolean $clear true to clear the error from the session upon receipt
	 * @return string Return the error message
	 */
	public static function get_error( $key, $clear = true )
	{
		$errors = self::get_errors( false );
		if ( isset( $errors[$key] ) ) {
			$error = $errors[$key];
			if ( $clear ) {
				self::remove_error( $key );
			}
			return $error;
		}
	}

	/**
	 * Removes a specific notice from the stored notices.
	 *
	 * @param string $key ID of the notice to remove
	 * @return boolean True or false depending if the notice was removed successfully.
	 */
	public static function remove_notice( $key )
	{
		unset( $_SESSION['notices'][$key] );
		return ( !isset( $_SESSION['notices'][$key] ) ? true : false );
	}

	/**
	 * Removes a specific error from the stored errors.
	 *
	 * @param string $key ID of the error to remove
	 * @return boolean True or false depending if the error was removed successfully.
	 */
	public static function remove_error( $key )
	{
		unset( $_SESSION['errors'][$key] );
		return ( !isset( $_SESSION['errors'][$key] ) ? true : false );
	}

	/**
	 * Return output of notice and error messages
	 *
	 * @param boolean $clear true to clear the messages from the session upon receipt
	 * @param array $callback a reference to a callback function for formatting the the messages
	 * @return mixed output of messages
	 */
	public static function messages_get( $clear = true, $callback = null )
	{
		$errors = self::get_errors( $clear );
		$notices = self::get_notices( $clear );

		// if callback is 'array', then just return the raw data
		if ( $callback == 'array' ) {
			$output = array_merge( $errors, $notices );
		}
		// if a function is passed in $callback, call it
		else if ( isset( $callback ) && is_callable( $callback ) ) {
			$output = call_user_func( $callback, $notices, $errors );
		}
		// default to html output
		else {
			$output = Format::html_messages( $notices, $errors );
		}

		return $output;
	}

	/**
	 * Output notice and error messages
	 *
	 * @param boolean $clear true to clear the messages from the session upon receipt
	 * @param array $callback a reference to a callback function for formatting the the messages
	 */
	public static function messages_out( $clear = true, $callback = null )
	{
		echo self::messages_get( $clear, $callback );
	}

	/**
	 * Determine if there are messages that should be displayed
	 * Messages are not cleared when calling this function.
	 *
	 * @return boolean true if there are messages to display.
	 */
	public static function has_messages()
	{
		return ( count( self::get_notices( false ) + self::get_errors( false ) ) ) ? true : false;
	}

	/**
	 * Determine if there are error messages to display
	 *
	 * @param string $key Optional key of the unique error message
	 * @return boolean true if there are errors, false if not
	 */
	public static function has_errors( $key = null )
	{
		if ( isset( $key ) ) {
			return isset( $_SESSION['errors'][$key] );
		}
		else {
			return count( self::get_errors( false ) ) ? true : false;
		}
	}

	protected static function get_subnet( $remote_address = '' )
	{
		
		// if it's an ipv6 address, we just use that and don't try to determine the subnet
		$is_v6 = filter_var( $remote_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 );
		
		if ( $is_v6 !== false ) {
			return $remote_address;
		}
		
		$long_addr = ip2long( $remote_address );

		if ( $long_addr >= ip2long( '0.0.0.0' ) && $long_addr <= ip2long( '127.255.255.255' ) ) {
			// class A
			return sprintf( "%u", $long_addr ) >> 24;
		}
		else if ( $long_addr >= ip2long( '128.0.0.0' ) && $long_addr <= ip2long( '191.255.255.255' ) ) {
			// class B
			return sprintf( "%u", $long_addr ) >> 16;
		}
		else {
			// class C, D, or something we missed
			return sprintf( "%u", $long_addr ) >> 8;
		}

	}

}

?>
