<?php
/**
 * @package     FrameworkOnFramework
 * @subpackage  dispatcher
 * @copyright   Copyright (C) 2010-2016 Nicholas K. Dionysopoulos / Akeeba Ltd. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */
// Protect from unauthorized access
defined('F0F_INCLUDED') or die;

/**
 * FrameworkOnFramework dispatcher class
 *
 * FrameworkOnFramework is a set of classes which extend Joomla! 1.5 and later's
 * MVC framework with features making maintaining complex software much easier,
 * without tedious repetitive copying of the same code over and over again.
 *
 * @package  FrameworkOnFramework
 * @since    1.0
 */
class F0FDispatcher extends F0FUtilsObject
{
	/** @var array Configuration variables */
	protected $config = array();

	/** @var F0FInput Input variables */
	protected $input = array();

	/** @var string The name of the default view, in case none is specified */
	public $defaultView = 'cpanel';

	// Variables for F0F's transparent user authentication. You can override them
	// in your Dispatcher's __construct() method.

	/** @var int The Time Step for the TOTP used in F0F's transparent user authentication */
	protected $fofAuth_timeStep = 6;

	/** @var string The key for the TOTP, Base32 encoded (watch out; Base32, NOT Base64!) */
	protected $fofAuth_Key = null;

	/** @var array Which formats to be handled by transparent authentication */
	protected $fofAuth_Formats = array('json', 'csv', 'xml', 'raw');

	/**
	 * Should I logout the transparently authenticated user on logout?
	 * Recommended to leave it on in order to avoid crashing the sessions table.
	 *
	 * @var boolean
	 */
	protected $fofAuth_LogoutOnReturn = true;

	/** @var array Which methods to use to fetch authentication credentials and in which order */
	protected $fofAuth_AuthMethods = array(
		/* HTTP Basic Authentication using encrypted information protected
		 * with a TOTP (the username must be "_fof_auth") */
		'HTTPBasicAuth_TOTP',
		/* Encrypted information protected with a TOTP passed in the
		 * _fofauthentication query string parameter */
		'QueryString_TOTP',
		/* HTTP Basic Authentication using a username and password pair in plain text */
		'HTTPBasicAuth_Plaintext',
		/* Plaintext, JSON-encoded username and password pair passed in the
		 * _fofauthentication query string parameter */
		'QueryString_Plaintext',
		/* Plaintext username and password in the _fofauthentication_username
		 * and _fofauthentication_username query string parameters */
		'SplitQueryString_Plaintext',
	);

	/** @var bool Did we successfully and transparently logged in a user? */
	private $_fofAuth_isLoggedIn = false;

	/** @var string The calculated encryption key for the _TOTP methods, used if we have to encrypt the reply */
	private $_fofAuth_CryptoKey = '';

	/**
	 * Get a static (Singleton) instance of a particular Dispatcher
	 *
	 * @param   string  $option  The component name
	 * @param   string  $view    The View name
	 * @param   array   $config  Configuration data
	 *
	 * @staticvar  array  $instances  Holds the array of Dispatchers F0F knows about
	 *
	 * @return  F0FDispatcher
	 */
	public static function &getAnInstance($option = null, $view = null, $config = array())
	{
		static $instances = array();

		$hash = $option . $view;

		if (!array_key_exists($hash, $instances))
		{
			$instances[$hash] = self::getTmpInstance($option, $view, $config);
		}

		return $instances[$hash];
	}

	/**
	 * Gets a temporary instance of a Dispatcher
	 *
	 * @param   string  $option  The component name
	 * @param   string  $view    The View name
	 * @param   array   $config  Configuration data
	 *
	 * @return F0FDispatcher
	 */
	public static function &getTmpInstance($option = null, $view = null, $config = array())
	{
		if (array_key_exists('input', $config))
		{
			if ($config['input'] instanceof F0FInput)
			{
				$input = $config['input'];
			}
			else
			{
				if (!is_array($config['input']))
				{
					$config['input'] = (array) $config['input'];
				}

				$config['input'] = array_merge($_REQUEST, $config['input']);
				$input = new F0FInput($config['input']);
			}
		}
		else
		{
			$input = new F0FInput;
		}

		$config['option']   = !is_null($option) ? $option : $input->getCmd('option', 'com_foobar');
		$config['view']     = !is_null($view) ? $view : $input->getCmd('view', '');

		$input->set('option', $config['option']);
		$input->set('view', $config['view']);

		$config['input'] = $input;

		$className = ucfirst(str_replace('com_', '', $config['option'])) . 'Dispatcher';

		if (!class_exists($className))
		{
			$componentPaths = F0FPlatform::getInstance()->getComponentBaseDirs($config['option']);

			$searchPaths = array(
				$componentPaths['main'],
				$componentPaths['main'] . '/dispatchers',
				$componentPaths['admin'],
				$componentPaths['admin'] . '/dispatchers'
			);

			if (array_key_exists('searchpath', $config))
			{
				array_unshift($searchPaths, $config['searchpath']);
			}

			$filesystem = F0FPlatform::getInstance()->getIntegrationObject('filesystem');

			$path = $filesystem->pathFind(
					$searchPaths, 'dispatcher.php'
			);

			if ($path)
			{
				require_once $path;
			}
		}

		if (!class_exists($className))
		{
			$className = 'F0FDispatcher';
		}

		$instance = new $className($config);

		return $instance;
	}

	/**
	 * Public constructor
	 *
	 * @param   array  $config  The configuration variables
	 */
	public function __construct($config = array())
	{
		// Cache the config
		$this->config = $config;

		// Get the input for this MVC triad
		if (array_key_exists('input', $config))
		{
			$this->input = $config['input'];
		}
		else
		{
			$this->input = new F0FInput;
		}

		// Get the default values for the component name
		$this->component = $this->input->getCmd('option', 'com_foobar');

		// Load the component's fof.xml configuration file
		$configProvider = new F0FConfigProvider;
		$this->defaultView = $configProvider->get($this->component . '.dispatcher.default_view', $this->defaultView);

		// Get the default values for the view name
		$this->view = $this->input->getCmd('view', null);

		if (empty($this->view))
		{
			// Do we have a task formatted as controller.task?
			$task = $this->input->getCmd('task', '');

			if (!empty($task) && (strstr($task, '.') !== false))
			{
				list($this->view, $task) = explode('.', $task, 2);
				$this->input->set('task', $task);
			}
		}

		if (empty($this->view))
		{
			$this->view = $this->defaultView;
		}

		$this->layout = $this->input->getCmd('layout', null);

		// Overrides from the config
		if (array_key_exists('option', $config))
		{
			$this->component = $config['option'];
		}

		if (array_key_exists('view', $config))
		{
			$this->view = empty($config['view']) ? $this->view : $config['view'];
		}

		if (array_key_exists('layout', $config))
		{
			$this->layout = $config['layout'];
		}

		$this->input->set('option', $this->component);
		$this->input->set('view', $this->view);
		$this->input->set('layout', $this->layout);

		if (array_key_exists('authTimeStep', $config))
		{
			$this->fofAuth_timeStep = empty($config['authTimeStep']) ? 6 : $config['authTimeStep'];
		}
	}

    /**
     * The main code of the Dispatcher. It spawns the necessary controller and
     * runs it.
     *
     * @throws Exception
     *
     * @return  void|Exception
     */
	public function dispatch()
	{
        $platform = F0FPlatform::getInstance();

		if (!$platform->authorizeAdmin($this->input->getCmd('option', 'com_foobar')))
		{
            return $platform->raiseError(403, JText::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'));
		}

		$this->transparentAuthentication();

		// Merge English and local translations
		$platform->loadTranslations($this->component);

		$canDispatch = true;

		if ($platform->isCli())
		{
			$canDispatch = $canDispatch && $this->onBeforeDispatchCLI();
		}

		$canDispatch = $canDispatch && $this->onBeforeDispatch();

		if (!$canDispatch)
		{
            // We can set header only if we're not in CLI
            if(!$platform->isCli())
            {
                $platform->setHeader('Status', '403 Forbidden', true);
            }

            return $platform->raiseError(403, JText::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'));
		}

		// Get and execute the controller
		$option = $this->input->getCmd('option', 'com_foobar');
		$view   = $this->input->getCmd('view', $this->defaultView);
		$task   = $this->input->getCmd('task', null);

		if (empty($task))
		{
			$task = $this->getTask($view);
		}

		// Pluralise/sungularise the view name for typical tasks
		if (in_array($task, array('edit', 'add', 'read')))
		{
			$view = F0FInflector::singularize($view);
		}
		elseif (in_array($task, array('browse')))
		{
			$view = F0FInflector::pluralize($view);
		}

		$this->input->set('view', $view);
		$this->input->set('task', $task);

		$config = $this->config;
		$config['input'] = $this->input;

		$controller = F0FController::getTmpInstance($option, $view, $config);
		$status = $controller->execute($task);

		if (!$this->onAfterDispatch())
		{
            // We can set header only if we're not in CLI
            if(!$platform->isCli())
            {
                $platform->setHeader('Status', '403 Forbidden', true);
            }

            return $platform->raiseError(403, JText::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'));
		}

		$format = $this->input->get('format', 'html', 'cmd');
		$format = empty($format) ? 'html' : $format;

		if ($controller->hasRedirect())
		{
			$controller->redirect();
		}
	}

	/**
	 * Tries to guess the controller task to execute based on the view name and
	 * the HTTP request method.
	 *
	 * @param   string  $view  The name of the view
	 *
	 * @return  string  The best guess of the task to execute
	 */
	protected function getTask($view)
	{
		// Get a default task based on plural/singular view
		$request_task = $this->input->getCmd('task', null);
		$task = F0FInflector::isPlural($view) ? 'browse' : 'edit';

		// Get a potential ID, we might need it later
		$id = $this->input->get('id', null, 'int');

		if ($id == 0)
		{
			$ids = $this->input->get('ids', array(), 'array');

			if (!empty($ids))
			{
				$id = array_shift($ids);
			}
		}

		// Check the request method

		if (!isset($_SERVER['REQUEST_METHOD']))
		{
			$_SERVER['REQUEST_METHOD'] = 'GET';
		}

		$requestMethod = strtoupper($_SERVER['REQUEST_METHOD']);

		switch ($requestMethod)
		{
			case 'POST':
			case 'PUT':
				if (!is_null($id))
				{
					$task = 'save';
				}
				break;

			case 'DELETE':
				if ($id != 0)
				{
					$task = 'delete';
				}
				break;

			case 'GET':
			default:
				// If it's an edit without an ID or ID=0, it's really an add
				if (($task == 'edit') && ($id == 0))
				{
					$task = 'add';
				}

				// If it's an edit in the frontend, it's really a read
				elseif (($task == 'edit') && F0FPlatform::getInstance()->isFrontend())
				{
					$task = 'read';
				}
				break;
		}

		return $task;
	}

	/**
	 * Executes right before the dispatcher tries to instantiate and run the
	 * controller.
	 *
	 * @return  boolean  Return false to abort
	 */
	public function onBeforeDispatch()
	{
		return true;
	}

	/**
	 * Sets up some environment variables, so we can work as usually on CLI, too.
	 *
	 * @return  boolean  Return false to abort
	 */
	public function onBeforeDispatchCLI()
	{
		JLoader::import('joomla.environment.uri');
		JLoader::import('joomla.application.component.helper');

		// Trick to create a valid url used by JURI
		$this->_originalPhpScript = '';

		// We have no Application Helper (there is no Application!), so I have to define these constants manually
		$option = $this->input->get('option', '', 'cmd');

		if ($option)
		{
			$componentPaths = F0FPlatform::getInstance()->getComponentBaseDirs($option);

			if (!defined('JPATH_COMPONENT'))
			{
				define('JPATH_COMPONENT', $componentPaths['main']);
			}

			if (!defined('JPATH_COMPONENT_SITE'))
			{
				define('JPATH_COMPONENT_SITE', $componentPaths['site']);
			}

			if (!defined('JPATH_COMPONENT_ADMINISTRATOR'))
			{
				define('JPATH_COMPONENT_ADMINISTRATOR', $componentPaths['admin']);
			}
		}

		return true;
	}

	/**
	 * Executes right after the dispatcher runs the controller.
	 *
	 * @return  boolean  Return false to abort
	 */
	public function onAfterDispatch()
	{
		// If we have to log out the user, please do so now
		if ($this->fofAuth_LogoutOnReturn && $this->_fofAuth_isLoggedIn)
		{
			F0FPlatform::getInstance()->logoutUser();
		}

		return true;
	}

	/**
	 * Transparently authenticates a user
	 *
	 * @return  void
	 */
	public function transparentAuthentication()
	{
		// Only run when there is no logged in user
		if (!F0FPlatform::getInstance()->getUser()->guest)
		{
			return;
		}

		// @todo Check the format
		$format = $this->input->getCmd('format', 'html');

		if (!in_array($format, $this->fofAuth_Formats))
		{
			return;
		}

		foreach ($this->fofAuth_AuthMethods as $method)
		{
			// If we're already logged in, don't bother
			if ($this->_fofAuth_isLoggedIn)
			{
				continue;
			}

			// This will hold our authentication data array (username, password)
			$authInfo = null;

			switch ($method)
			{
				case 'HTTPBasicAuth_TOTP':

					if (empty($this->fofAuth_Key))
					{
						continue;
					}

					if (!isset($_SERVER['PHP_AUTH_USER']))
					{
						continue;
					}

					if (!isset($_SERVER['PHP_AUTH_PW']))
					{
						continue;
					}

					if ($_SERVER['PHP_AUTH_USER'] != '_fof_auth')
					{
						continue;
					}

					$encryptedData = $_SERVER['PHP_AUTH_PW'];

					$authInfo = $this->_decryptWithTOTP($encryptedData);
					break;

				case 'QueryString_TOTP':
					$encryptedData = $this->input->get('_fofauthentication', '', 'raw');

					if (empty($encryptedData))
					{
						continue;
					}

					$authInfo = $this->_decryptWithTOTP($encryptedData);
					break;

				case 'HTTPBasicAuth_Plaintext':
					if (!isset($_SERVER['PHP_AUTH_USER']))
					{
						continue;
					}

					if (!isset($_SERVER['PHP_AUTH_PW']))
					{
						continue;
					}

					$authInfo = array(
						'username'	 => $_SERVER['PHP_AUTH_USER'],
						'password'	 => $_SERVER['PHP_AUTH_PW']
					);
					break;

				case 'QueryString_Plaintext':
					$jsonencoded = $this->input->get('_fofauthentication', '', 'raw');

					if (empty($jsonencoded))
					{
						continue;
					}

					$authInfo = json_decode($jsonencoded, true);

					if (!is_array($authInfo))
					{
						$authInfo = null;
					}
					elseif (!array_key_exists('username', $authInfo) || !array_key_exists('password', $authInfo))
					{
						$authInfo = null;
					}
					break;

				case 'SplitQueryString_Plaintext':
					$authInfo = array(
						'username'	 => $this->input->get('_fofauthentication_username', '', 'raw'),
						'password'	 => $this->input->get('_fofauthentication_password', '', 'raw'),
					);

					if (empty($authInfo['username']))
					{
						$authInfo = null;
					}

					break;

				default:
					continue;

					break;
			}

			// No point trying unless we have a username and password
			if (!is_array($authInfo))
			{
				continue;
			}

			$this->_fofAuth_isLoggedIn = F0FPlatform::getInstance()->loginUser($authInfo);
		}
	}

	/**
	 * Decrypts a transparent authentication message using a TOTP
	 *
	 * @param   string  $encryptedData  The encrypted data
	 *
     * @codeCoverageIgnore
	 * @return  array  The decrypted data
	 */
	private function _decryptWithTOTP($encryptedData)
	{
		if (empty($this->fofAuth_Key))
		{
			$this->_fofAuth_CryptoKey = null;

			return null;
		}

		$totp = new F0FEncryptTotp($this->fofAuth_timeStep);
		$period = $totp->getPeriod();
		$period--;

		for ($i = 0; $i <= 2; $i++)
		{
			$time = ($period + $i) * $this->fofAuth_timeStep;
			$otp = $totp->getCode($this->fofAuth_Key, $time);
			$this->_fofAuth_CryptoKey = hash('sha256', $this->fofAuth_Key . $otp);

			$aes = new F0FEncryptAes($this->_fofAuth_CryptoKey);
			$ret = $aes->decryptString($encryptedData);
			$ret = rtrim($ret, "\000");

			$ret = json_decode($ret, true);

			if (!is_array($ret))
			{
				continue;
			}

			if (!array_key_exists('username', $ret))
			{
				continue;
			}

			if (!array_key_exists('password', $ret))
			{
				continue;
			}

			// Successful decryption!
			return $ret;
		}

		// Obviously if we're here we could not decrypt anything. Bail out.
		$this->_fofAuth_CryptoKey = null;

		return null;
	}

	/**
	 * Creates a decryption key for use with the TOTP decryption method
	 *
	 * @param   integer  $time  The timestamp used for TOTP calculation, leave empty to use current timestamp
	 *
     * @codeCoverageIgnore
	 * @return  string  THe encryption key
	 */
	private function _createDecryptionKey($time = null)
	{
		$totp = new F0FEncryptTotp($this->fofAuth_timeStep);
		$otp = $totp->getCode($this->fofAuth_Key, $time);

		$key = hash('sha256', $this->fofAuth_Key . $otp);

		return $key;
	}

	/**
	 * Main function to detect if we're running in a CLI environment and we're admin
	 *
	 * @return  array  isCLI and isAdmin. It's not an associtive array, so we can use list.
	 */
	public static function isCliAdmin()
	{
		static $isCLI   = null;
		static $isAdmin = null;

		if (is_null($isCLI) && is_null($isAdmin))
		{
			$isCLI   = F0FPlatform::getInstance()->isCli();
			$isAdmin = F0FPlatform::getInstance()->isBackend();
		}

		return array($isCLI, $isAdmin);
	}
}
