<?php

/**
 * THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
 * WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
 *
 * @package fusionLib
 * @copyright Copyright (c) 2013 - 2016 fusionLib. All rights reserved.
 * @link http://fusionlib.com
 */

/**
 * flUserAdapter Class
 *
 * User authentication adaptor for using a local database to authenticate against.
 * Also acts as base class for other adapters so that they can update local of data.
 *
 * @package fusionLib
 */
class flUserAdapter {
	/**
	 * The database object.
	 *
	 * @var flDb
	 */
	protected $db;

	/**
	 * Flags if not to send events.
	 *
	 * @var bool
	 */
	protected $notifyEvents;

	/**
	 * Construct the adapter.
	 *
	 * @param flDb $db The system database object.
	 * @param flConfig $cfg The system configuration object.
	 * @param bool $notifyEvents Flags if to send user event notifications.
	 */
	function __construct($db, $cfg = null, $notifyEvents = true) {
		$this->db = $db;
		$this->notifyEvents = $notifyEvents;
	}

	/**
	 * Authenticate the user with the given name and password.
	 *
	 * @param string $ip The client IP address.
	 * @param string $username The name of the user or external service name e.g. facebook.
	 * @param string $password The MD5 of the users password or external serivce user id e.g. facebook user id.
	 * @param string $usernameEmail Select if $username holds a username 'username', an email address 'email', 'external' if using external services such as Facebook, or null if unknown.
	 * @param bool $updateAccessTime true to update the last access time; else false.
	 * @return array Array of user data and auth state.
	 */
	function authenticate($ip, $username, $password, $usernameEmail, $updateAccessTime = true) {
		
		// If username/email not specified then guess
		if($usernameEmail === null)
			$usernameEmail = $this->db->fetchOne('SELECT COUNT(*) FROM fl_users WHERE username=?', $username) ? 'username' : 'email';

		// Purge IP block records older than 15min
		$now = new flDate();
		$this->db->query('DELETE FROM fl_usersipblocks WHERE lasthit<?', $now->setTimestamp($now->getTimestamp() - 900)->toString());

		// Get the current failure count from this IP and if less than 5 allow login
		$ip = md5($ip);
		if(!($attempts = $this->db->fetchOne('SELECT hitcount FROM fl_usersipblocks WHERE ip=?', $ip)))
			$attempts = 0;
		
		// If available attempts and external auth, convert to uid look up
		$extraSQL = ' && service IS NULL';
		if($attempts < 5 && $usernameEmail == 'external') {
			if($exuid = $this->db->fetchOne('SELECT uid FROM fl_users_ext WHERE service=? && authcode=?', $username, $password)) {
				$password = $username . '|' . $password;
				$usernameEmail = 'uid';
				$username = $exuid;
				$extraSQL = '';
			}
		}
		
		if($attempts < 5 && ($r = $this->_auth($usernameEmail . '=? && passwd=MD5(CONCAT(?, salt))' . $extraSQL, array($username, $password))) && $r['authState'] == flUser::AUTH_OK) {
			// Delete previous failures and flag logged in
			if($attempts)
				$this->db->delete('fl_usersipblocks', 'ip=?', $ip);

			if($updateAccessTime)
				$this->db->update('fl_users', array('lastaccess' => (string)(new flDate())), 'uid=?', $r['uid']);

			return $r;
		}
		// Log failure
		else {
			// Update attempts
			$attempts++;
			$now = new flDate();
			$this->db->replace('fl_usersipblocks', array('ip' => $ip, 'hitcount' => $attempts, 'lasthit' => $now->toString()));
			return array('authState' => $attempts >= 5 ? flUser::AUTH_IP_BLOCKED : ($r ? $r['authState'] : flUser::AUTH_UNKNOWN));
		}
	}

	/**
	 * Validate the user id, data payload and signature.
	 *
	 * @param int $uid The user id to validate.
	 * @param string $data The data to be verified.
	 * @param string $signature The signature of the data.
	 * @param bool $updateAccessTime true to update the last access time; else false.
	 * @return array The array of user data and auth state.
	 */
	function validate($uid, $data, $signature, $updateAccessTime = true) {
		$r = $this->_auth('uid=?', array($uid));
		if($r['authState'] == flUser::AUTH_OK) {
			// Validate the signature
			if($signature != hash_hmac('md5', $data, $r['token']))
				$r = array('authState' => flUser::AUTH_FAILED);
			else if($updateAccessTime)
				$this->db->update('fl_users', array('lastaccess' => (string)(new flDate())), 'uid=?', $r['uid']);
		}
		return $r;
	}

	/**
	 * Regenerate the authentication token.
	 *
	 * @param int $uid The ID of the user to regenerate.
	 * @return array Array of user data and state.
	 */
	function regenerate($uid) {
		// Update the token salt
		$tokensalt = mt_rand(0, 2147483647);
		$this->db->update('fl_users', array('tokensalt' => $tokensalt), 'uid=?', $uid);

		// Get the token
		return $this->_auth('uid=?', array($uid));
	}

	/**
	 * Internal function to perform authentication of the user against the database.
	 *
	 * @param string $where The where clause for matching the user.
	 * @param array $params The parameters for the where clause.
	 * @return array Array of user data and state.
	 */
	protected function _auth($where, $params) {

		// Get the user data
		if(!($data = $this->db->fetchRow('SELECT uid,username,displayname,passwd,email,tz,authkey,state,tokensalt,blockliftat,bio,service,authcode FROM fl_users LEFT JOIN fl_usersblocks USING(uid) LEFT JOIN fl_users_ext USING (uid) WHERE ' . $where, $params)))
			return array('authState' => flUser::AUTH_BAD_USER_PASS);

		// if blocked
		if($data['blockliftat'] != null) {
			// Test if block can be lifted
			$now = new flDate();
			if($data['blockliftat'] != null && $now->compare(new flDate($data['blockliftat'])) > 0) {
				$this->db->delete('fl_usersblocks', 'uid=?', $data['uid']);
				$this->db->query("UPDATE fl_users SET `state`='active' WHERE uid=?", $data['uid']);
				$data['state'] = 'active';
			}
			// Still blocked
			else
				return array('authState' => flUser::AUTH_BLOCKED);
		}

		// If not active
		if($data['state'] != 'active') {
			switch($data['state']) {
				case 'pending': return array('authState' => flUser::AUTH_PENDING_ACTIVATION); break;
				default: return array('authState' => flUser::AUTH_DISABLED);
			}
		}

		return array(
			'uid' => $data['uid'],
			'username' => $data['username'],
			'displayname' => $data['displayname'],
			'email' => $data['email'],
			'tz' => $data['tz'],
			'token' => md5($data['passwd'] . $data['uid'] . $data['tokensalt']),
			'authState' => flUser::AUTH_OK,
			'authkey' => $data['authkey'],
			'bio' => $data['bio'],
			'service' => $data['service'] == null ? 'internal' : $data['service'],
			'authcode' => $data['authcode']
		);
	}

	/**
	 * Change a field.
	 *
	 * @param int $uid The ID of the user to update.
	 * @param string $what What to change (passwd,username,email,tz, state,displayname,authkey,bio)
	 * @param string $value The new value to set, for passwd is the MD5 of the password.
	 * @return bool true if the change has been made; else false/null.
	 */
	function change($uid, $what, $value) {
		// Test what is valid
		if(!in_array($what, array('passwd', 'username', 'displayname', 'email', 'tz', 'state', 'authkey', 'bio')))
			return false;

		// If password then special case
		if($what == 'passwd') {
			$salt = mt_rand(0, 2147483647);
			return $this->db->update('fl_users', array('salt' => $salt, 'passwd' => md5($value . $salt)), 'uid=?', $uid);
		}
		// Special case for state
		else if($what == 'state') {
			$this->db->query('DELETE FROM fl_usersblocks WHERE uid=?', $uid);
			return $this->db->update('fl_users', array('state' => $value), 'uid=?', $uid);
		}
		else {
			if($what == 'displayname') $value = fusionLib::htmlChars($value);
			$r = $this->db->update('fl_users', array($what => $value), 'uid=?', $uid);
			if($this->notifyEvents && $r) {
				if($what == 'username')
					flApp::getEventManager()->notify(new flEvent('system.usernameUpdated', array('id' => $uid, 'username' => $value)));
				else if($what == 'email')
					flApp::getEventManager()->notify(new flEvent('system.userEmailUpdated', array('id' => $uid, 'email' => $value)));
			}
			return $r;
		}
	}

	/**
	 * Check for the presence of a username or email address.
	 *
	 * @param string $what What to check for (username, email)
	 * @param string $value The value to check for.
	 * @return bool true if a user exists that matches the check; else false.
	 */
	function check($what, $value) {
		if(!in_array($what, array('username', 'email')))
			return true;
		return $this->db->fetchOne("SELECT COUNT(*) FROM fl_users WHERE $what=?", $value) ? true : false;
	}

	/**
	 * Create a user within the system.
	 *
	 * @param string $username The username or null if creating an unnamed user.
	 * @param string $displayName The display name for the user.
	 * @param string $email The users email address.
	 * @param string $password The MD5 of the users password or array of external service ['service', 'authcode'].
	 * @param string $state The state to create the user in: 'active','disabled','pending'
	 * @param string $tz The timezone string for the user.
	 * @param int $forceID null to auto assign an ID or the ID to force, only valid for local accounts.
	 * @param string $bio The users bio.
	 * @return int The user id or 0 if the user could not be created.
	 */
	function create($username, $displayName, $email, $password, $state, $tz, $forceID = null, $bio = '') {
		$time = new flDate();
		$salt = mt_rand(0, 2147483647);
		$tokensalt = mt_rand(0, 2147483647);

		// Deal with external auth
		if(is_array($password)) {
			$pass = $password['service'] . '|' . $password['authcode'];

			// If null username then generate one from the display name
			if($username == null && !empty($displayName)) {
				$un = explode(' ', $displayName, 2);
				if(count($un) && !empty($un[0])) {
					$un = preg_replace('/^[^a-z0-9_\.-]+$/', '', strtolower($un[0]));
					if(empty($un))
						$un = null;
					else {
						$uMgr = new flUserMgr($this->db, $this);

						// Make it unique
						$c = -1;
						do {
							$c++;
							$uList = $uMgr->find($un . ($c ? $c : ''), array('username'), true);
						} while (count($uList));
						$un .= $c ? $c : '';
					}
				}
				else
					$un = null;

				$username = $un;
			}
		}
		else
			$pass = $password;

		// Attempt to add the user
		$d = array(
			'username' => $username === null ? md5($email . time()) : $username,
			'displayname' => fusionLib::htmlChars($displayName),
			'passwd' => md5($pass . $salt),
			'email' => $email,
			'tz' => $tz,
			'state' => $state,
			'added' => $time->toString(),
			'lastaccess' => null,
			'lastslaveupd' => null,
			'salt' => $salt,
			'tokensalt' => $tokensalt,
			'authkey' => '',
			'bio' => $bio
		);
		if($forceID !== null)
			$d['uid'] = $forceID;

		if($this->db->insert('fl_users', $d)) {
			// Get the user ID
			$uid = $this->db->insertID();

			// If unnamed then set username
			if($username === null)
				$this->db->update('fl_users', array('username' => md5($uid . '__fusionLib.User__')), 'uid=?', $uid);

			// If extneral
			if(is_array($password)) {
				$this->db->insert('fl_users_ext', array(
					'uid'      => $uid,
					'service'  => $password['service'],
					'authcode' => $password['authcode']
				));
			}

			if($this->notifyEvents)
				flApp::getEventManager()->notify(new flEvent('system.userAdded', array('id' => $uid)));
		}
		else
			$uid = 0;

		return $uid;
	}

	/**
	 * Set the roles for a user.
	 *
	 * @param int $uid The ID of the user to set the roles for.
	 * @param array $roles Array of role names to set.
	 * @return bool true if the roles were set; else false.
	 */
	function setRoles($uid, $roles) {
		if(!$this->db->fetchOne('SELECT COUNT(*) FROM fl_users WHERE uid=?', $uid))
			return false;

		// Delete old roles
		$this->db->delete('fl_usersroles', 'uid=?', $uid);

		// Convert roles to IDs and set
		if(count($roles)) {
			$results = $this->db->query('SELECT roleid FROM fl_roles WHERE rolename IN(?' . str_repeat(',?', count($roles) - 1) . ')', $roles);
			while($row = $results->fetch())
				$this->db->insert('fl_usersroles', array('uid' => $uid, 'roleid' => $row['roleid']));
		}

		return true;
	}

	/**
	 * Get the names of the roles assigned to the user.
	 *
	 * @param int $uid The ID of the user to get the roles for.
	 * @return array Array of role names.
	 */
	function getRoles($uid) {
		$r = array();
		$result = $this->db->query("SELECT roleid,rolename FROM fl_usersroles LEFT JOIN fl_roles USING(roleid) WHERE uid=?", $uid);
		while($row = $result->fetch())
			$r[$row['roleid']] = $row['rolename'];
		return $r;
	}

	/**
	 * Get the information on a user.
	 *
	 * @param int $uid The ID of the user to get.
	 * @return array The user information or false/null on error.
	 */
	function getInfo($uid) {
		$r = $this->db->fetchRow("SELECT username,displayname,email,tz,authkey,added,lastaccess,IF(blockliftat IS NULL, state, 'blocked') AS state,passwd,tokensalt,bio,service,authcode FROM fl_users LEFT JOIN fl_usersblocks USING(uid) LEFT JOIN fl_users_ext USING(uid) WHERE uid=?", $uid);
		if($r) {
			$r['token'] = md5($r['passwd'] . $uid . $r['tokensalt']);
			unset($r['passwd'], $r['tokensalt']);
			$r['service'] = $r['service'] == null ? 'internal' : $r['service'];
		}
		return $r;
	}

	/**
	 * Delete the user.
	 *
	 * @param int $uid The ID of the user to delete.
	 * @return bool true if deleted; else false.
	 */
	function delete($uid) {
		$this->db->delete('fl_users', 'uid=?', $uid);
		$this->db->delete('fl_usersblocks', 'uid=?', $uid);
		$this->db->delete('fl_usersroles', 'uid=?', $uid);
		$this->db->delete('fl_users_ext', 'uid=?', $uid);

		if($this->notifyEvents)
			flApp::getEventManager()->notify(new flEvent('system.userDeleted', array('id' => $uid)));

		return true;
	}

	/**
	 * Block a user.
	 *
	 * @param int $uid The id of the user to block.
	 * @param int $blockedBy The id of the user applying the block.
	 * @param int $for The time in days to block the account for or 0 to block forever.
	 * @param string $reason The reason for the block.
	 * @return bool true if the block was applied; else false.
	 */
	function block($uid, $blockedBy, $for, $reason) {
		$t = new flDate();

		$this->db->query("UPDATE fl_users SET `state`='blocked' WHERE uid=?", $uid);

		return $this->db->insert('fl_usersblocks', array(
			'uid' => $uid,
			'blockedby' => $blockedBy,
			'blockedat' => $t->toString(),
			'blockliftat' => $for ? ($t->setTimestamp($t->getTimestamp() + ($for * 86400))->toString()) : null,
			'blockreason' => $reason));
	}

	/**
	 * Get the information about a block on a user.
	 *
	 * @param int $uid The id of the user to get the block information about.
	 * @return array Array holding the block information or null if the user is not blocked.
	 */
	function getBlockInfo($uid) {
		if($r = $this->db->fetchRow('SELECT blockedby,blockedat,blockliftat,blockreason,username,displayname AS blockedbyusername FROM fl_usersblocks LEFT JOIN fl_users ON(blockedby=fl_users.uid) WHERE fl_usersblocks.uid=?', $uid)) {
			if($r['blockliftat'] != null) {
				$lift = new flDate($r['blockliftat']);
				$blocked = new flDate($r['blockedat']);
				$r['blockeddays'] = ($lift->getTimestamp() - $blocked->getTimestamp()) / 86400;
			}
			else
				$r['blockeddays'] = 0;
		}
		return $r;
	}
}
