<?php namespace ProcessWire;

/**
 * Session handler for storing sessions to database
 *
 * @see /wire/core/SessionHandler.php
 *
 * ProcessWire 3.x, Copyright 2021 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.
 * @property int $retrySeconds Seconds after which to retry after a lock fail.
 *
 */

class SessionHandlerDB extends WireSessionHandler implements Module, ConfigurableModule {

	public static function getModuleInfo() {
		return array(
			'title' => 'Session Handler Database', 
			'version' => 6, 
			'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
		$this->set('retrySeconds', 30); // seconds after which to retry on a lock fail
	}
	
	public function wired() {
		$this->database = $this->wire()->database;
		parent::wired();
	}
	
	public function init() {
		parent::init();
		// keeps session active
		$this->wire()->session->setFor($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);
		$locked = $query->fetchColumn();
		$query->closeCursor();
	
		if(!$locked) {
			// 0: attempt timed out (for example, because another client has previously locked the name)
			// null: error occurred (such as running out of memory or the thread was killed with mysqladmin kill)
			$this->wire()->shutdown->setFatalErrorResponse(array(
				'code' => 429, // http status 429: Too Many Requests (RFC 6585)
				'headers' => array("Retry-After: $this->retrySeconds"),
			));
			throw new WireException("Unable to obtain lock for session (retry in {$this->retrySeconds}s)", 429);
		}
		
		$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 = ''; 
		
		if($this->useIP) {
			$session = $this->wire()->session;
			$ip = $session->getIP();
			$ip = (strlen($ip) && strpos($ip, ':') === false ? ip2long($ip) : '');
			// @todo DB schema for ipv6
		}
	
		$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()";
		
		try {
			$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();
		} catch(\Exception $e) {
			$result = false;
			$this->trackException($e);
		}
		
		try {
			$query = $database->prepare("DO RELEASE_LOCK(:id)");
			$query->bindValue(':id', $id, \PDO::PARAM_STR);
			$database->execute($query, false);
			$query->closeCursor();
		} catch(\Exception $e) {
			$this->trackException($e);
		}
		
		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) {
		$config = $this->wire()->config;
		$table = self::dbTableName;
		$database = $this->database;
		$query = $database->prepare("DELETE FROM `$table` WHERE id=:id"); 
		$query->execute(array(":id" => $id));
		$secure = $config->sessionCookieSecure ? (bool) $config->https : false;
		$expires = time() - 42000;
		$samesite = $config->sessionCookieSameSite ? ucfirst(strtolower($config->sessionCookieSameSite)) : 'Lax';
		
		if($samesite === 'None') $secure = true;
		
		if(PHP_VERSION_ID < 70300) {
			setcookie(session_name(), '', $expires, "/; SameSite=$samesite", $config->sessionCookieDomain, $secure, true);
		} else {
			setcookie(session_name(), '', array(
				'expires' => $expires,
				'path' => '/',
				'domain' => $config->sessionCookieDomain,
				'secure' => $secure,
				'httponly' => true,
				'samesite' => $samesite
			));
		}
		
		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) {

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

		// check if their DB table is the latest version
		$query = $this->database->query("SHOW COLUMNS FROM " . self::dbTableName . " WHERE field='ip'"); 
		if(!$query->rowCount()) {
			$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.');

		/** @var InputfieldCheckbox $f */
		$f = $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 = $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 = $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);

		/** @var InputfieldInteger $f */
		$f = $modules->get('InputfieldInteger'); 
		$f->attr('name', 'lockSeconds'); 
		$f->attr('value', $this->lockSeconds);
		$f->label = $this->_('Session lock timeout (seconds)'); 
		$f->description = sprintf(
			$this->_('If a DB lock for the session cannot be obtained in this many seconds, a “%s” error will be sent, telling the client to retry again in %d seconds.'), 
			$this->_('429: Too Many Requests'), 
			30
		); 
		$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) {
		$seconds = (int) $seconds;
		$sql = "SELECT COUNT(*) FROM " . self::dbTableName . " WHERE ts > DATE_SUB(NOW(), INTERVAL $seconds SECOND)";
		$query = $this->database->query($sql);
		$numSessions = (int) $query->fetchColumn();
		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->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->database->prepare($sql);
		$query->bindValue(':id', $sessionID); 
		$this->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
	 * 
	 */
	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->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->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();
	}

}
