<?php
/*
 * Copyright 2011-2013 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file is distributed
 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */


/**
 * Provides an interface for using Amazon DynamoDB as a session store by hooking into
 * PHP's session handler hooks. This class is not auto-loaded, and must be included
 * manually or via the <code>AmazonDynamoDB::register_session_hander()</code> method.
 *
 * Once registered, You may use the native <code>$_SESSION</code> superglobal and
 * session functions, and the sessions will be stored automatically in DynamoDB.
 */
class DynamoDBSessionHandler
{
	/**
	 * @var AmazonDynamoDB The DyanamoDB client.
	 */
	protected $_dynamodb = null;

	/**
	 * @var string The session save path (see <php:session_save_path()>).
	 */
	protected $_save_path = null;

	/**
	 * @var string The session name (see <php:session_name()>).
	 */
	protected $_session_name = null;

	/**
	 * @var boolean Keeps track of if the session is open.
	 */
	protected $_open_session = null;

	/**
	 * @var boolean Keeps track of whether the session is open.
	 */
	protected $_session_written = false;

	/**
	 * @var string The name of the DynamoDB table in which to store sessions.
	 */
	protected $_table_name = 'sessions';

	/**
	 * @var string The name of the hash key in the DynamoDB sessions table.
	 */
	protected $_hash_key = 'id';

	/**
	 * @var integer The lifetime of an inactive session before it should be garbage collected.
	 */
	protected $_session_lifetime = 0;

	/**
	 * @var boolean Whether or not the session handler should do consistent reads from DynamoDB.
	 */
	protected $_consistent_reads = true;

	/**
	 * @var boolean Whether or not the session handler should do session locking.
	 */
	protected $_session_locking = true;

	/**
	 * @var integer Maximum time, in seconds, that the session handler should take to acquire a lock.
	 */
	protected $_max_lock_wait_time = 30;

	/**
	 * @var integer Minimum time, in microseconds, that the session handler should wait to retry acquiring a lock.
	 */
	protected $_min_lock_retry_utime = 10000;

	/**
	 * @var integer Maximum time, in microseconds, that the session handler should wait to retry acquiring a lock.
	 */
	protected $_max_lock_retry_utime = 100000;

	/**
	 * @var array Type for casting the configuration options.
	 */
	protected static $_option_types = array(
		'table_name'           => 'string',
		'hash_key'             => 'string',
		'session_lifetime'     => 'integer',
		'consistent_reads'     => 'boolean',
		'session_locking'      => 'boolean',
		'max_lock_wait_time'   => 'integer',
		'min_lock_retry_utime' => 'integer',
		'max_lock_retry_utime' => 'integer',
	);

	/**
	 * Initializes the session handler and prepares the configuration options.
	 *
	 * @param AmazonDynamoDB $dynamodb (Required) An instance of the DynamoDB client.
	 * @param array $options (Optional) Configuration options.
	 */
	public function __construct(AmazonDynamoDB $dynamodb, array $options = array())
	{
		// Store the AmazonDynamoDB client for use in the session handler
		$this->_dynamodb = $dynamodb;

		// Do type conversions on options and store the values
		foreach ($options as $key => $value)
		{
			if (isset(self::$_option_types[$key]))
			{
				settype($value, self::$_option_types[$key]);
				$this->{'_' . $key} = $value;
			}
		}

		// Make sure the lifetime is positive. Use the gc_maxlifetime otherwise
		if ($this->_session_lifetime <= 0)
		{
			$this->_session_lifetime = (integer) ini_get('session.gc_maxlifetime');
		}
	}

	/**
	 * Destruct the session handler and make sure the session gets written.
	 *
	 * NOTE: It is usually better practice to call <code>session_write_close()</code>
	 * manually in your application as soon as session modifications are complete. This
	 * is especially true if session locking is enabled (which it is by default).
	 *
	 * @see http://php.net/manual/en/function.session-set-save-handler.php#refsect1-function.session-set-save-handler-notes
	 */
	public function __destruct()
	{
		session_write_close();
	}

	/**
	 * Register DynamoDB as a session handler.
	 *
	 * Uses the PHP-provided method to register this class as a session handler.
	 *
	 * @return DynamoDBSessionHandler Chainable.
	 */
	public function register()
	{
		session_set_save_handler(
			array($this, 'open'),
			array($this, 'close'),
			array($this, 'read'),
			array($this, 'write'),
			array($this, 'destroy'),
			array($this, 'garbage_collect')
		);

		return $this;
	}

	/**
	 * Checks if the session is open and writable.
	 *
	 * @return boolean Whether or not the session is still open for writing.
	 */
	public function is_session_open()
	{
		return (boolean) $this->_open_session;
	}

	/**
	 * Delegates to <code>session_start()</code>
	 *
	 * @return DynamoDBSessionHandler Chainable.
	 */
	public function open_session()
	{
		session_start();

		return $this;
	}

	/**
	 * Delegates to <code>session_commit()</code>
	 *
	 * @return DynamoDBSessionHandler Chainable.
	 */
	public function close_session()
	{
		session_commit();

		return $this;
	}

	/**
	 * Delegates to <code>session_destroy()</code>
	 *
	 * @return DynamoDBSessionHandler Chainable.
	 */
	public function destroy_session()
	{
		session_destroy();

		return $this;
	}

	/**
	 * Open a session for writing. Triggered by <php:session_start()>.
	 *
	 * Part of the standard PHP session handler interface.
	 *
	 * @param string $save_path (Required) The session save path (see <php:session_save_path()>).
	 * @param string $session_name (Required) The session name (see <php:session_name()>).
	 * @return boolean Whether or not the operation succeeded.
	 */
	public function open($save_path, $session_name)
	{
		$this->_save_path    = $save_path;
		$this->_session_name = $session_name;
		$this->_open_session = session_id();

		return true;
	}

	/**
	 * Close a session from writing
	 *
	 * Part of the standard PHP session handler interface
	 *
	 * @return boolean Success
	 */
	public function close()
	{
		if (!$this->_session_written)
		{
			// Ensure that the session is unlocked even if the write did not happen
			$id = $this->_open_session;
			$response = $this->_dynamodb->update_item(array(
				'TableName'        => $this->_table_name,
				'Key'              => array('HashKeyElement' => $this->_dynamodb->attribute($this->_id($id))),
				'AttributeUpdates' => array(
					'expires'  => $this->_dynamodb->attribute(time() + $this->_session_lifetime, 'update'),
					'lock'     => array('Action' => AmazonDynamoDB::ACTION_DELETE)
				),
			));

			$this->_session_written = $response->isOK();
		}

		$this->_open_session = null;

		return $this->_session_written;
	}

	/**
	 * Read a session stored in DynamoDB
	 *
	 * Part of the standard PHP session handler interface
	 *
	 * @param string $id (Required) The session ID.
	 * @return string The session data.
	 */
	public function read($id)
	{
		$result = '';

		// Get the session data from DynamoDB (acquire a lock if locking is enabled)
		if ($this->_session_locking)
		{
			$response = $this->_lock_and_read($id);
			$node_name = 'Attributes';
		}
		else
		{
			$response = $this->_dynamodb->get_item(array(
				'TableName'      => $this->_table_name,
				'Key'            => array('HashKeyElement' => $this->_dynamodb->attribute($this->_id($id))),
				'ConsistentRead' => $this->_consistent_reads,
			));
			$node_name = 'Item';
		}

		if ($response && $response->isOK())
		{
			$item = array();

			// Get the data from the DynamoDB response
			if ($response->body->{$node_name})
			{
				foreach ($response->body->{$node_name}->children() as $key => $value)
				{
					$type = $value->children()->getName();
					$item[$key] = $value->{$type}->to_string();
				}
			}

			if (isset($item['expires']) && isset($item['data']))
			{
				// Check the expiration date before using
				if ($item['expires'] > time())
				{
					$result = $item['data'];
				}
				else
				{
					$this->destroy($id);
				}
			}
		}

		return $result;
	}

	/**
	 * Write a session to DynamoDB.
	 *
	 * Part of the standard PHP session handler interface.
	 *
	 * @param string $id (Required) The session ID.
	 * @param string $data (Required) The session data.
	 * @return boolean Whether or not the operation succeeded.
	 */
	public function write($id, $data)
	{
		// Write the session data to DynamoDB
		$response = $this->_dynamodb->put_item(array(
			'TableName' => $this->_table_name,
			'Item'      => $this->_dynamodb->attributes(array(
				$this->_hash_key => $this->_id($id),
				'expires'        => time() + $this->_session_lifetime,
				'data'           => $data,
			)),
		));

		$this->_session_written = $response->isOK();

		return $this->_session_written;
	}

	/**
	 * Delete a session stored in DynamoDB.
	 *
	 * Part of the standard PHP session handler interface.
	 *
	 * @param string $id (Required) The session ID.
	 * @param boolean $garbage_collect_mode (Optional) Whether or not the handler is doing garbage collection.
	 * @return boolean Whether or not the operation succeeded.
	 */
	public function destroy($id, $garbage_collect_mode = false)
	{
		// Make sure we don't prefix the ID a second time
		if (!$garbage_collect_mode)
		{
			$id = $this->_id($id);
		}

		$delete_options = array(
			'TableName' => $this->_table_name,
			'Key'       => array('HashKeyElement' => $this->_dynamodb->attribute($id)),
		);

		// Make sure not to garbage collect locked sessions
		if ($garbage_collect_mode && $this->_session_locking)
		{
			$delete_options['Expected'] = array('lock' => array('Exists' => false));
		}

		// Send the delete request to DynamoDB
		$response = $this->_dynamodb->delete_item($delete_options);

		$this->_session_written = $response->isOK();

		return $this->_session_written;
	}

	/**
	 * Performs garbage collection on the sessions stored in the DynamoDB table.
	 *
	 * Part of the standard PHP session handler interface.
	 *
	 * @param integer $maxlifetime (Required) The value of <code>session.gc_maxlifetime</code>. Ignored.
	 * @return boolean Whether or not the operation succeeded.
	 */
	public function garbage_collect($maxlifetime = null)
	{
		// Send a search request to DynamoDB looking for expired sessions
		$response = $this->_dynamodb->scan(array(
			'TableName'  => $this->_table_name,
			'ScanFilter' => array('expires' => array(
				'AttributeValueList' => array($this->_dynamodb->attribute(time())),
				'ComparisonOperator' => AmazonDynamoDB::CONDITION_LESS_THAN,
			)),
		));

		// Delete the expired sessions
		if ($response->isOK())
		{
			$deleted = array();

			// Get the ID of and delete each session that is expired
			foreach ($response->body->Items as $item)
			{
				$id = (string) $item->{$this->_hash_key}->{AmazonDynamoDB::TYPE_STRING};
				$deleted[$id] = $this->destroy($id, true);
			}

			// Return true if all of the expired sessions were deleted
			return (array_sum($deleted) === count($deleted));
		}

		return false;
	}

	/**
	 * Creates a table in DynamoDB for session storage according to provided configuration options.
	 *
	 * Note: Table creation may also be done via the AWS Console, which might make the most sense for this use case.
	 *
	 * @param integer $read_capacity_units (Required) Read capacity units for table throughput.
	 * @param integer $write_capacity_units (Required) Write capacity units for table throughput.
	 * @return boolean Returns <code>true</code> on success.
	 */
	public function create_sessions_table($read_capacity_units = 10, $write_capacity_units = 5)
	{
		$response = $this->_dynamodb->create_table(array(
			'TableName' => $this->_table_name,
			'KeySchema' => array(
				'HashKeyElement' => array(
					'AttributeName' => $this->_hash_key,
					'AttributeType' => AmazonDynamoDB::TYPE_STRING,
				)
			),
			'ProvisionedThroughput' => array(
				'ReadCapacityUnits' => $read_capacity_units,
				'WriteCapacityUnits' => $write_capacity_units,
			)
		));

		return $response->isOK();
	}

	/**
	 * Deletes the session storage table from DynamoDB.
	 *
	 * Note: Table deletion may also be done via the AWS Console, which might make the most sense for this use case.
	 *
	 * @return boolean Returns <code>true</code> on success.
	 */
	public function delete_sessions_table()
	{
		$response = $this->_dynamodb->delete_table(array('TableName' => $this->_table_name));

		return $response->isOK();
	}

	/**
	 * Prefix the session ID with the session name and prepare for DynamoDB usage
	 *
	 * @param string $id (Required) The session ID.
	 * @return array The HashKeyElement value formatted as an array.
	 */
	protected function _id($id)
	{
		return trim($this->_session_name . '_' . $id, '_');
	}

	/**
	 * Acquires a lock on a session in DynamoDB using conditional updates.
	 *
	 * WARNING: There is a <code>while(true);</code> in here.
	 *
	 * @param string $id (Required) The session ID.
	 * @return CFResponse The response from DynamoDB.
	 */
	protected function _lock_and_read($id)
	{
		$now = time();
		$timeout = $now + $this->_max_lock_wait_time;

		do
		{
			// Acquire the lock
			$response = $this->_dynamodb->update_item(array(
				'TableName'        => $this->_table_name,
				'Key'              => array('HashKeyElement' => $this->_dynamodb->attribute($this->_id($id))),
				'AttributeUpdates' => array('lock' => $this->_dynamodb->attribute(1, 'update')),
				'Expected'         => array('lock' => array('Exists' => false)),
				'ReturnValues'     => 'ALL_NEW',
			));

			// If lock succeeds (or times out), exit the loop, otherwise sleep and try again
			if ($response->isOK() || $now >= $timeout)
			{
				return $response;
			}
			elseif (stripos((string) $response->body->asXML(), 'ConditionalCheckFailedException') !== false)
			{
				usleep(rand($this->_min_lock_retry_utime, $this->_max_lock_retry_utime));

				$now = time();
			}
			else
			{
				return null;
			}
		}
		while (true);
	}
}
