<?php

/**
 * @file classes/handler/PKPHandler.inc.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2000-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @package core
 * @class PKPHandler
 *
 * Base request handler abstract class.
 *
 */

class PKPHandler {
	/** @var string|null API token */
	protected $_apiToken = null;

	/**
	 * @var string identifier of the controller instance - must be unique
	 *  among all instances of a given controller type.
	 */
	var $_id;

	/** @var Dispatcher, mainly needed for cross-router url construction */
	var $_dispatcher;

	/** @var array validation checks for this page - deprecated! */
	var $_checks = array();

	/**
	 * @var array
	 *  The value of this variable should look like this:
	 *  array(
	 *    ROLE_ID_... => array(...allowed handler operations...),
	 *    ...
	 *  )
	 */
	var $_roleAssignments = array();

	/** @var AuthorizationDecisionManager authorization decision manager for this handler */
	var $_authorizationDecisionManager;

	/** @var boolean Whether to enforce site access restrictions. */
	var $_enforceRestrictedSite = true;

	/** @var boolean Whether role assignments have been checked. */
	var $_roleAssignmentsChecked = false;

	/** @var boolean Whether this is a handler for a page in the backend editorial UI */
	var $_isBackendPage = false;

	/**
	 * Constructor
	 */
	function __construct() {
	}

	//
	// Setters and Getters
	//
	function setEnforceRestrictedSite($enforceRestrictedSite) {
		$this->_enforceRestrictedSite = $enforceRestrictedSite;
	}

	/**
	 * Set the controller id
	 * @param $id string
	 */
	function setId($id) {
		$this->_id = $id;
	}

	/**
	 * Get the controller id
	 * @return string
	 */
	function getId() {
		return $this->_id;
	}

	/**
	 * Get the dispatcher
	 *
	 * NB: The dispatcher will only be set after
	 * handler instantiation. Calling getDispatcher()
	 * in the constructor will fail.
	 *
	 * @return Dispatcher
	 */
	function &getDispatcher() {
		assert(!is_null($this->_dispatcher));
		return $this->_dispatcher;
	}

	/**
	 * Set the dispatcher
	 * @param $dispatcher PKPDispatcher
	 */
	function setDispatcher($dispatcher) {
		$this->_dispatcher = $dispatcher;
	}

	/**
	 * Fallback method in case request handler does not implement index method.
	 * @param $args array
	 * @param $request PKPRequest
	 */
	function index($args, $request) {
		$dispatcher = $this->getDispatcher();
		if (isset($dispatcher)) $dispatcher->handle404();
		else Dispatcher::handle404(); // For old-style handlers
	}

	/**
	 * Add an authorization policy for this handler which will
	 * be applied in the authorize() method.
	 *
	 * Policies must be added in the class constructor or in the
	 * subclasses' authorize() method before the parent::authorize()
	 * call so that PKPHandler::authorize() will be able to enforce
	 * them.
	 *
	 * @param $authorizationPolicy AuthorizationPolicy
	 * @param $addToTop boolean whether to insert the new policy
	 *  to the top of the list.
	 */
	function addPolicy($authorizationPolicy, $addToTop = false) {
		if (is_null($this->_authorizationDecisionManager)) {
			// Instantiate the authorization decision manager
			import('lib.pkp.classes.security.authorization.AuthorizationDecisionManager');
			$this->_authorizationDecisionManager = new AuthorizationDecisionManager();
		}

		// Add authorization policies to the authorization decision manager.
		$this->_authorizationDecisionManager->addPolicy($authorizationPolicy, $addToTop);
	}

	/**
	 * Retrieve authorized context objects from the decision manager.
	 * 
	 * Gets an object that was previously stored using the same assoc type.
	 * The authorization policies populate these -- when an object is fetched
	 * and checked for permission in the policy class, it's then chucked into
	 * the authorized context for later retrieval by code that needs it.
	 * 
	 * @param $assocType integer any of the ASSOC_TYPE_* constants
	 * @return mixed
	 */
	function &getAuthorizedContextObject($assocType) {
		assert(is_a($this->_authorizationDecisionManager, 'AuthorizationDecisionManager'));
		return $this->_authorizationDecisionManager->getAuthorizedContextObject($assocType);
	}

	/**
	 * Get the authorized context.
	 *
	 * NB: You should avoid accessing the authorized context
	 * directly to avoid accidentally overwriting an object
	 * in the context. Try to use getAuthorizedContextObject()
	 * instead where possible.
	 *
	 * @return array
	 */
	function &getAuthorizedContext() {
		assert(is_a($this->_authorizationDecisionManager, 'AuthorizationDecisionManager'));
		return $this->_authorizationDecisionManager->getAuthorizedContext();
	}

	/**
	 * Retrieve the last authorization message from the
	 * decision manager.
	 * @return string
	 */
	function getLastAuthorizationMessage() {
		if (!is_a($this->_authorizationDecisionManager, 'AuthorizationDecisionManager')) {
			return '';
		}
		$authorizationMessages = $this->_authorizationDecisionManager->getAuthorizationMessages();
		return end($authorizationMessages);
	}

	/**
	 * Add role - operation assignments to the handler.
	 *
	 * @param $roleIds integer|array one or more of the ROLE_ID_*
	 *  constants
	 * @param $operations string|array a single method name or
	 *  an array of method names to be assigned.
	 */
	function addRoleAssignment($roleIds, $operations) {
		// Allow single operations to be passed in as scalars.
		if (!is_array($operations)) $operations = array($operations);

		// Allow single roles to be passed in as scalars.
		if (!is_array($roleIds)) $roleIds = array($roleIds);

		// Add the given operations to all roles.
		foreach($roleIds as $roleId) {
			// Create an empty assignment array if no operations
			// have been assigned to the given role before.
			if (!isset($this->_roleAssignments[$roleId])) {
				$this->_roleAssignments[$roleId] = array();
			}

			// Merge the new operations with the already assigned
			// ones for the given role.
			$this->_roleAssignments[$roleId] = array_merge(
				$this->_roleAssignments[$roleId],
				$operations
			);
		}

		// Flag role assignments as needing checking.
		$this->_roleAssignmentsChecked = false;
	}

	/**
	 * This method returns an assignment of operation names for the
	 * given role.
	 * @param $roleId int
	 * @return array assignment for the given role.
	 */
	function getRoleAssignment($roleId) {
		if (!is_null($roleId)) {
			if (isset($this->_roleAssignments[$roleId])) {
				return $this->_roleAssignments[$roleId];
			} else {
				return null;
			}
		}
	}

	/**
	 * This method returns an assignment of roles to operation names.
	 *
	 * @return array assignments for all roles.
	 */
	function getRoleAssignments() {
		return $this->_roleAssignments;
	}

	/**
	 * Flag role assignment checking as completed.
	 */
	function markRoleAssignmentsChecked() {
		$this->_roleAssignmentsChecked = true;
	}

	/**
	 * Authorize this request.
	 *
	 * Routers will call this method automatically thereby enforcing
	 * authorization. This method will be called before the
	 * validate() method and before passing control on to the
	 * handler operation.
	 *
	 * NB: This method will be called once for every request only.
	 *
	 * @param $request Request
	 * @param $args array request arguments
	 * @param $roleAssignments array the operation role assignment,
	 *  see getRoleAssignment() for more details.
	 * @return boolean
	 */
	function authorize($request, &$args, $roleAssignments) {
		// Enforce restricted site access if required.
		if ($this->_enforceRestrictedSite) {
			import('lib.pkp.classes.security.authorization.RestrictedSiteAccessPolicy');
			$this->addPolicy(new RestrictedSiteAccessPolicy($request), true);
		}

		// Enforce SSL site-wide.
		if ($this->requireSSL()) {
			import('lib.pkp.classes.security.authorization.HttpsPolicy');
			$this->addPolicy(new HttpsPolicy($request), true);
		}

		if (!defined('SESSION_DISABLE_INIT')) {
			// Add user roles in authorized context.
			$user = $request->getUser();
			if (is_a($user, 'User') || is_a($request->getRouter(), 'APIRouter')) {
				import('lib.pkp.classes.security.authorization.UserRolesRequiredPolicy');
				$this->addPolicy(new UserRolesRequiredPolicy($request), true);
			}
		}

		// Make sure that we have a valid decision manager instance.
		assert(is_a($this->_authorizationDecisionManager, 'AuthorizationDecisionManager'));

		$router = $request->getRouter();
		if (is_a($router, 'PKPPageRouter')) {
			// We have to apply a blacklist approach for page
			// controllers to maintain backwards compatibility:
			// Requests are implicitly authorized if no policy
			// explicitly denies access.
			$this->_authorizationDecisionManager->setDecisionIfNoPolicyApplies(AUTHORIZATION_PERMIT);
		} else {
			// We implement a strict whitelist approach for
			// all other components: Requests will only be
			// authorized if at least one policy explicitly
			// grants access and none denies access.
			$this->_authorizationDecisionManager->setDecisionIfNoPolicyApplies(AUTHORIZATION_DENY);
		}

		// Let the authorization decision manager take a decision.
		$decision = $this->_authorizationDecisionManager->decide();
		if ($decision == AUTHORIZATION_PERMIT && (empty($this->_roleAssignments) || $this->_roleAssignmentsChecked)) {
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Perform data integrity checks.
	 *
	 * This method will be called once for every request only.
	 *
	 * NB: Any kind of authorization check is now deprecated
	 * within this method. This method is purely meant for data
	 * integrity checks that do not lead to denial of access
	 * to resources (e.g. via redirect) like handler operations
	 * or data objects.
	 *
	 * @param $requiredContexts array
	 * @param $request Request
	 */
	function validate($requiredContexts = null, $request = null) {
		// FIXME: for backwards compatibility only - remove when request/router refactoring complete
		if (!isset($request)) {
			$request =& Registry::get('request');
			if (Config::getVar('debug', 'deprecation_warnings')) trigger_error('Deprecated call without request object.');
		}

		foreach ($this->_checks as $check) {
			// Using authorization checks in the validate() method is deprecated
			// FIXME: Trigger a deprecation warning.

			// check should redirect on fail and continue on pass
			// default action is to redirect to the index page on fail
			if ( !$check->isValid() ) {
				if ( $check->redirectToLogin ) {
					Validation::redirectLogin();
				} else {
					// An unauthorized page request will be re-routed
					// to the index page.
					$request->redirect(null, 'index');
				}
			}
		}

		return true;
	}

	/**
	 * Subclasses can override this method to configure the
	 * handler.
	 *
	 * NB: This method will be called after validation and
	 * authorization.
	 *
	 * @param $request PKPRequest
	 */
	function initialize($request) {
		// Set the controller id to the requested
		// page (page routing) or component name
		// (component routing) by default.
		$router = $request->getRouter();
		if (is_a($router, 'PKPComponentRouter')) {
			$componentId = $router->getRequestedComponent($request);
			// Create a somewhat compressed but still globally unique
			// and human readable component id.
			// Example: "grid.citation.CitationGridHandler"
			// becomes "grid-citation-citationgrid"
			$componentId = str_replace('.', '-', PKPString::strtolower(PKPString::substr($componentId, 0, -7)));
			$this->setId($componentId);
		} elseif (is_a($router, 'APIRouter')) {
			$this->setId($router->getEntity());
		} else {
			assert(is_a($router, 'PKPPageRouter'));
			$this->setId($router->getRequestedPage($request));
		}
	}

	/**
	 * Return the DBResultRange structure and misc. variables describing the current page of a set of pages.
	 * @param $request PKPRequest
	 * @param $rangeName string Symbolic name of range of pages; must match the Smarty {page_list ...} name.
	 * @param $contextData array If set, this should contain a set of data that are required to
	 * 	define the context of this request (for maintaining page numbers across requests).
	 *	To disable persistent page contexts, set this variable to null.
	 * @return DBResultRange
	 */
	static function getRangeInfo($request, $rangeName, $contextData = null) {
		$context = $request->getContext();
		$pageNum = $request->getUserVar(self::getPageParamName($rangeName));
		if (empty($pageNum)) {
			$session =& $request->getSession();
			$pageNum = 1; // Default to page 1
			if ($session && $contextData !== null) {
				// See if we can get a page number from a prior request
				$contextHash = self::hashPageContext($request, $contextData);

				if ($request->getUserVar('clearPageContext')) {
					// Explicitly clear the old page context
					$session->unsetSessionVar("page-$contextHash");
				} else {
					$oldPage = $session->getSessionVar("page-$contextHash");
					if (is_numeric($oldPage)) $pageNum = $oldPage;
				}
			}
		} else {
			$session =& $request->getSession();
			if ($session && $contextData !== null) {
				// Store the page number
				$contextHash = self::hashPageContext($request, $contextData);
				$session->setSessionVar("page-$contextHash", $pageNum);
			}
		}

		if ($context) $count = $context->getData('itemsPerPage');
		if (!isset($count)) $count = Config::getVar('interface', 'items_per_page');

		import('lib.pkp.classes.db.DBResultRange');

		if (isset($count)) return new DBResultRange($count, $pageNum);
		else return new DBResultRange(-1, -1);
	}

	/**
	 * Get the range info page parameter name.
	 * @param $rangeName string
	 * @return string
	 */
	static function getPageParamName($rangeName) {
		return $rangeName . 'Page';
	}

	/**
	 * Set up the basic template.
	 * @param $request PKPRequest
	 */
	function setupTemplate($request) {
		// FIXME: for backwards compatibility only - remove
		if (!isset($request)) {
			$request =& Registry::get('request');
			if (Config::getVar('debug', 'deprecation_warnings')) trigger_error('Deprecated call without request object.');
		}
		assert(is_a($request, 'PKPRequest'));

		AppLocale::requireComponents(
			LOCALE_COMPONENT_PKP_COMMON,
			LOCALE_COMPONENT_PKP_USER,
			LOCALE_COMPONENT_APP_COMMON
		);

		$userRoles = (array) $this->getAuthorizedContextObject(ASSOC_TYPE_USER_ROLES);
		if (array_intersect(array(ROLE_ID_MANAGER), $userRoles)) {
			AppLocale::requireComponents(LOCALE_COMPONENT_PKP_MANAGER);
		}

		$templateMgr = TemplateManager::getManager($request);
		$templateMgr->assign('userRoles', $userRoles);

		$accessibleWorkflowStages = $this->getAuthorizedContextObject(ASSOC_TYPE_ACCESSIBLE_WORKFLOW_STAGES);
		if ($accessibleWorkflowStages) $templateMgr->assign('accessibleWorkflowStages', $accessibleWorkflowStages);

		// Set up template requirements for the backend editorial UI
		if ($this->_isBackendPage) {
			$templateMgr->setupBackendPage();
		}
	}

	/**
	 * Generate a unique-ish hash of the page's identity, including all
	 * context that differentiates it from other similar pages (e.g. all
	 * articles vs. all articles starting with "l").
	 * @param $request PKPRequest
	 * @param $contextData array A set of information identifying the page
	 * @return string hash
	 */
	static function hashPageContext($request, $contextData = array()) {
		return md5(
			implode(',', $request->getRequestedContextPath()) . ',' .
			$request->getRequestedPage() . ',' .
			$request->getRequestedOp() . ',' .
			serialize($contextData)
		);
	}

	/**
	 * Return the context that is configured in site redirect setting.
	 * @param $request Request
	 * @return mixed Either Context or null
	 */
	function getSiteRedirectContext($request) {
		$site = $request->getSite();
		if ($site && ($contextId = $site->getRedirect())) {
			$contextDao = Application::getContextDAO(); /* @var $contextDao ContextDAO */
			return $contextDao->getById($contextId);
		}
		return null;
	}

	/**
	 * Return the first context that user is enrolled with.
	 * @param $user User
	 * @param $contexts Array
	 * @return mixed Either Context or null
	 */
	function getFirstUserContext($user, $contexts) {
		$userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /* @var $userGroupDao UserGroupDAO */
		$context = null;
		foreach($contexts as $workingContext) {
			$userIsEnrolled = $userGroupDao->userInAnyGroup($user->getId(), $workingContext->getId());
			if ($userIsEnrolled) {
				$context = $workingContext;
				break;
			}
		}
		return $context;
	}

	/**
	 * Assume SSL is required for all handlers, unless overridden in subclasses.
	 * @return boolean
	 */
	function requireSSL() {
		return true;
	}

	/**
	 * Return API token string
	 *
	 * @return string|null
	 */
	public function getApiToken() {
		return $this->_apiToken;
	}

	/**
	 * Set API token string
	 *
	 */
	public function setApiToken($apiToken) {
		return $this->_apiToken = $apiToken;
	}

	/**
	 * Returns a "best-guess" context, based in the request data, if
	 * a request needs to have one in its context but may be in a site-level
	 * context as specified in the URL.
	 * @param $request Request
	 * @param $hasNoContexts boolean Optional reference to receive true iff no contexts were found.
	 * @return mixed Either a Context or null if none could be determined.
	 */
	function getTargetContext($request, &$hasNoContexts = null) {
		// Get the requested path.
		$router = $request->getRouter();
		$requestedPath = $router->getRequestedContextPath($request);

		if ($requestedPath === 'index' || $requestedPath === '') {
			// No context requested. Check how many contexts the site has.
			$contextDao = Application::getContextDAO(); /* @var $contextDao ContextDAO */
			$contexts = $contextDao->getAll(true);
			list($firstContext, $secondContext) = [$contexts->next(), $contexts->next()];
			if ($firstContext && !$secondContext) {
				// Return the unique context.
				$context = $firstContext;
				$hasNoContexts = false;
			} elseif ($firstContext && $secondContext) {
				// Get the site redirect.
				$context = $this->getSiteRedirectContext($request);
				$hasNoContexts = false;
			} else {
				$context = null;
				$hasNoContexts = true;
			}
		} else {
			// Return the requested context.
			$context = $router->getContext($request);

			// If the specified context does not exist, respond with a 404.
			if (!$context) $request->getDispatcher()->handle404();
		}
		if (is_a($context, 'Context')) {
			return $context;
		}
		return null;
	}
}


