<?php namespace ProcessWire;

/**
 * Session handler for storing sessions to database
 *
 * @see /wire/core/SessionHandler.php
 *
 * ProcessWire 3.x, Copyright 2018 by Ryan Cramer
 * https://processwire.com
 * 
 * @property int|bool $useIP Track IP address?
 * @property int|bool $useUA Track user agent?
 * @property int|bool $noPS Prevent more than one session per logged-in user?
 * @property int $lockSeconds Max number of seconds to wait to obtain DB row lock.
 *
 */

class SessionHandlerDB extends WireSessionHandler implements Module, ConfigurableModule {

	public static function getModuleInfo() {
		return array(
			'title' => 'Session Handler Database', 
			'version' => 5, 
			'summary' => "Installing this module makes ProcessWire store sessions in the database rather than the file system. Note that this module will log you out after install or uninstall.",
			'installs' => array('ProcessSessionDB')
			);
	}

	/**
	 * Table created by this module
	 *
	 */
	const dbTableName = 'sessions';

	/**
	 * Quick reference to database
	 * 
	 * @var WireDatabasePDO
	 *
	 */
	protected $database; 

	/**
	 * Construct
	 *
	 */
	public function __construct() {
		parent::__construct();
		$this->set('useIP', 0); // track IP address?
		$this->set('useUA', 0); // track user agent?
		$this->set('noPS', 0); // disallow parallel sessions per user
		$this->set('lockSeconds', 50); // max number of seconds to wait to obtain DB row lock
	}
	
	public function wired() {
		$this->database = $this->wire('database');
		parent::wired();
	}
	
	public function init() {
		parent::init();
		// keeps session active
		$this->wire('session')->set($this, 'ts', time());
		if($this->noPS) $this->addHookAfter('Session::loginSuccess', $this, 'hookLoginSuccess');
	}

	/**
	 * Read and return data for session indicated by $id
	 *
	 * @param string $id Session ID
	 * @return string Serialized data or blank string if none
	 *
	 */
	public function read($id) {
		
		$table = self::dbTableName; 
		$database = $this->database;
		$data = '';
		
		$query = $database->prepare("SELECT GET_LOCK(:id, :seconds)"); 
		$query->bindValue(':id', $id);
		$query->bindValue(':seconds', $this->lockSeconds, \PDO::PARAM_INT);
		$database->execute($query);
		
		$query = $database->prepare("SELECT data FROM `$table` WHERE id=:id");
		$query->bindValue(':id', $id);
		$database->execute($query);
		
		if($query->rowCount()) {
			$data = $query->fetchColumn();
			if(empty($data)) $data = '';
		}
		$query->closeCursor();
			
		return $data; 
	}

	/**
	 * Write the given $data for the given session ID
	 *
	 * @param string $id Session ID
	 * @param string $data Serialized data to write
	 * @return bool
	 *
	 */
	public function write($id, $data) {
		$table = self::dbTableName;
		$database = $this->database;
		$user = $this->wire('user');
		$page = $this->wire('page');
		$user_id = $user && $user->id ? (int) $user->id : 0; 
		$pages_id = $page && $page->id ? (int) $page->id : 0;
		$ua = ($this->useUA && isset($_SERVER['HTTP_USER_AGENT'])) ? substr(strip_tags($_SERVER['HTTP_USER_AGENT']), 0, 255) : '';
		$ip = $this->useIP ? ((int) $this->wire('session')->getIP(true)) : '';
	
		$binds = array(
			':id' => $id, 
			':user_id' => $user_id, 
			':pages_id' => $pages_id, 
			':data' => $data,
		);

		$s = "user_id=:user_id, pages_id=:pages_id, data=:data";
		
		if($ip) {
			$s .= ", ip=:ip";
			$binds[':ip'] = $ip;
		}
		if($ua) {
			$s .= ", ua=:ua";
			$binds[':ua'] = $ua;
		}
		
		$sql = "INSERT INTO $table SET id=:id, $s ON DUPLICATE KEY UPDATE $s, ts=NOW()";
		$query = $database->prepare($sql); 
		
		foreach($binds as $key => $value) {
			$type = is_int($value) ? \PDO::PARAM_INT : \PDO::PARAM_STR;
			$query->bindValue($key, $value, $type);
		}
		
		$result = $database->execute($query, false) ? true : false; 
		$query->closeCursor();
		
		$query = $database->prepare("DO RELEASE_LOCK(:id)");
		$query->bindValue(':id', $id, \PDO::PARAM_STR);
		$database->execute($query, false);
		$query->closeCursor();
		
		return $result; 
	}

	/**
	 * Destroy the session indicated by the given session ID
	 *
	 * @param string $id Session ID
	 * @return bool True on success, false on failure 
	 *
	 */
	public function destroy($id) {
		$table = self::dbTableName;
		$database = $this->database;
		$query = $database->prepare("DELETE FROM `$table` WHERE id=:id"); 
		$query->execute(array(":id" => $id));
		$secure = $this->wire('config')->sessionCookieSecure ? (bool) $this->config->https : false;
		setcookie(session_name(), '', time()-42000, '/', $this->config->sessionCookieDomain, $secure, true);
		return true; 
	}

	/**
	 * Garbage collection: remove stale sessions
	 *
	 * @param int $seconds Max lifetime of a session
	 * @return bool True on success, false on failure
	 *
	 */
	public function gc($seconds) {
		$table = self::dbTableName; 
		$seconds = (int) $seconds; 
		$sql = "DELETE FROM `$table` WHERE ts < DATE_SUB(NOW(), INTERVAL $seconds SECOND)";
		return $this->database->exec($sql) !== false;
	}

	/**
	 * Install sessions table
	 *
	 */
	public function ___install() {
		
		$table = self::dbTableName;
		$charset = $this->wire('config')->dbCharset;

		$sql = 	"CREATE TABLE `$table` (" . 
				"id CHAR(32) NOT NULL, " . 
				"user_id INT UNSIGNED NOT NULL, " . 
				"pages_id INT UNSIGNED NOT NULL, " . 
				"data MEDIUMTEXT NOT NULL, " . 
				"ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " . 
				"ip INT UNSIGNED NOT NULL DEFAULT 0, " . 
				"ua VARCHAR(250) NOT NULL DEFAULT '', " . 
				"PRIMARY KEY (id), " . 
				"INDEX (pages_id), " . 
				"INDEX (user_id), " . 
				"INDEX (ts) " . 
				") ENGINE=InnoDB DEFAULT CHARSET=$charset";

		$this->database->query($sql); 
	}

	/**
	 * Drop sessions table
	 *
	 */
	public function ___uninstall() {
		$this->database->query("DROP TABLE " . self::dbTableName); 
	}

	/**
	 * Session configuration options
	 * 
	 * @param array $data
	 * @return InputfieldWrapper
	 *
	 */
	public function getModuleConfigInputfields(array $data) {

		$form = $this->wire(new InputfieldWrapper());

		// check if their DB table is the latest version
		$query = $this->wire('database')->query("SHOW COLUMNS FROM " . self::dbTableName . " WHERE field='ip'"); 
		if(!$query->rowCount()) {
			$this->wire('modules')->error("DB format changed - You must uninstall this module and re-install before configuring."); 
			return $form;
		}

		$description = $this->_('Checking this box will enable the data to be displayed in your admin sessions list.');

		$f = $this->wire('modules')->get('InputfieldCheckbox'); 
		$f->attr('name', 'useIP'); 
		$f->attr('value', 1);
		$f->attr('checked', empty($data['useIP']) ? '' : 'checked'); 
		$f->label = $this->_('Track IP addresses in session data?');
		$f->description = $description;
		$form->add($f);

		$f = $this->wire('modules')->get('InputfieldCheckbox'); 
		$f->attr('name', 'useUA'); 
		$f->attr('value', 1);
		$f->attr('checked', empty($data['useUA']) ? '' : 'checked'); 
		$f->label = $this->_('Track user agent in session data?');
		$f->notes = $this->_('The user agent typically contains information about the browser being used.');
		$f->description = $description;
		$form->add($f);

		$f = $this->wire('modules')->get('InputfieldCheckbox');
		$f->attr('name', 'noPS');
		$f->attr('value', 1);
		$f->attr('checked', empty($data['noPS']) ? '' : 'checked');
		$f->label = $this->_('Disallow parallel sessions?');
		$f->notes = $this->_('When enabled, successful login expires all other sessions for that user on other devices/browsers.');
		$f->description = $this->_('Checking this box will allow only one single session for a logged-in user at a time.');
		$form->add($f);

		if(ini_get('session.gc_probability') == 0) {
			$form->warning(
				"Your PHP has a configuration error with regard to sessions. It is configured to never clean up old session files. " . 
				"Please correct this by adding the following to your <u>/site/config.php</u> file: " . 
				"<code>ini_set('session.gc_probability', 1);</code>", 
				Notice::allowMarkup
			);
		}

		return $form;
	}

	/**
	 * Provides direct reference access to set values in the $data array
	 *
	 * For some reason PHP 5.4+ requires this, as it apparently doesn't see WireData
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @return void
	 *
	 */
	public function __set($key, $value) {
		$this->set($key, $value);
	}


	/**
	 * Provides direct reference access to variables in the $data array
	 *
	 * For some reason PHP 5.4+ requires this, as it apparently doesn't see WireData
	 *
	 * Otherwise the same as get()
	 *
	 * @param string $key
	 * @return mixed
	 *
	 */
	public function __get($key) {
		return $this->get($key);
	}

	/**
	 * Return the number of active sessions in the last 5 mins (300 seconds)
	 * 
	 * @param int $seconds Optionally specify number of seconds (rather than 300, 5 minutes)
	 * @return int
	 * 
	 */
	public function getNumSessions($seconds = 300) {
		$sql = "SELECT count(*) FROM " . self::dbTableName . " WHERE ts > :ts";
		$query = $this->wire('database')->prepare($sql);
		$query->bindValue(':ts', date('Y-m-d H:i:s', (time() - $seconds)));
		$query->execute();
		list($numSessions) = $query->fetch(\PDO::FETCH_NUM); 
		return $numSessions; 
	}

	/**
	 * Get the most recent sessions
	 * 
	 * Returns an array of array for each session, which includes all the 
	 * session info except or the 'data' property. Use the getSessionData()
	 * method to retrieve that. 
	 * 
	 * @param int $seconds Sessions up to this many seconds old
	 * @param int $limit Max number of sessions to return
	 * @return array Sessions newest to oldest
	 * 
	 */
	public function getSessions($seconds = 300, $limit = 100) {
		
		$seconds = (int) $seconds; 
		$limit = (int) $limit; 
		
		$sql = 	"SELECT id, user_id, pages_id, ts, UNIX_TIMESTAMP(ts) AS tsu, ip, ua " . 
				"FROM " . self::dbTableName . " " . 
				"WHERE ts > DATE_SUB(NOW(), INTERVAL $seconds SECOND) " . 
				"ORDER BY ts DESC LIMIT $limit";
		
		$query = $this->wire('database')->prepare($sql); 
		$query->execute();
	
		$sessions = array();
		while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
			$sessions[] = $row; 
		}
		
		return $sessions; 
	}

	/**
	 * Return all session data for the given session ID
	 * 
	 * Note that the 'data' property of the returned array contains the values
	 * that the user has in their $session. 
	 * 
	 * @param $sessionID
	 * @return array Blank array on fail, populated array on success. 
	 * 
	 */
	public function getSessionData($sessionID) {
		$sql = "SELECT * FROM " . self::dbTableName . " WHERE id=:id";
		$query = $this->wire('database')->prepare($sql);
		$query->bindValue(':id', $sessionID); 
		$this->wire('database')->execute($query);
		if(!$query->rowCount()) return array();
		$row = $query->fetch(\PDO::FETCH_ASSOC) ;
		$sess = $_SESSION; // save
		session_decode($row['data']); 
		$row['data'] = $_SESSION; 
		$_SESSION = $sess; // restore
		return $row; 
	}

	/**
	 * Upgrade module version
	 * 
	 * @param int $fromVersion
	 * @param int $toVersion
	 * @throws WireException
	 * 
	 */
	public function ___upgrade($fromVersion, $toVersion) {
		// $this->message("Upgrade: $fromVersion => $toVersion");
		// if(version_compare($fromVersion, "0.0.5", "<") && version_compare($toVersion, "0.0.4", ">")) {
		if($fromVersion <= 4 && $toVersion >= 5) {	
			$table = self::dbTableName;
			$database = $this->wire('database');
			$sql = "ALTER TABLE $table MODIFY data MEDIUMTEXT NOT NULL";
			$query = $database->prepare($sql);
			$query->execute();
			$this->message("Updated sessions database for larger data storage", Notice::log);
		}
	}

	/**
	 * Hook called after Session::loginSuccess to enforce the noPS option
	 * 
	 * @param HookEvent $event
	 * 
	 */
	public function hookLoginSuccess(HookEvent $event) {
		if(!$this->noPS) return;
		/** @var User $user */
		$user = $event->arguments(0);
		$table = self::dbTableName;
		$query = $this->wire('database')->prepare("DELETE FROM `$table` WHERE user_id=:user_id AND id!=:id");
		$query->bindValue(':id', session_id()); 
		$query->bindValue(':user_id', $user->id, \PDO::PARAM_INT);  
		$query->execute();
		$n = $query->rowCount();
		if($n) $this->message(sprintf(
			$this->_('Previous login session for “%s” has been removed/logged-out.'), 
			$user->name
		));
		$query->closeCursor();
	}

}
