<?php
/**
* @file classes/core/PKPComponentRouter.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.
*
* @class PKPComponentRouter
* @ingroup core
*
* @brief Class mapping an HTTP request to a component handler operation.
*
* We are using an RPC style URL-to-endpoint mapping. Our approach follows
* a simple "convention-over-configuration" paradigm. If necessary the
* router can be subclassed to implement more complex URL-to-endpoint mappings.
*
* For servers with path info enabled the component URL has the following elements:
*
* .../index.php/context1/context2/$$$call$$$/path/to/handler-class/operation-name?arg1=...&arg2=...
*
* where "$$$call$$$" is a non-mutable literal string and "path/to" is
* by convention the directory path below the "controllers" folder leading to the
* component. The next element ("handler-class" in this example) will be mapped to a
* component class file by "camelizing" the string to "HandlerClassHandler" and adding
* ".inc.php" to the end. The "operation-name" is transformed to "operationName"
* and represents the name of the handler method to be called. Finally "arg1", "arg2",
* etc. are parameters to be passed along to the handler method.
*
* For servers with path info disabled the component URL looks like this:
*
* .../index.php?component=path.to.handler-class&op=operation-name&arg1=...&arg2=...
*
* The router will sanitize the request URL to a certain amount to make sure that
* random code inclusions are prevented. User authorization and parameter validation
* are however not the router's concern. These must be implemented on handler level.
*
* NB: Component and operation names may only contain a-z, 0-9 and hyphens. Numbers
* are not allowed at the beginning of a name or after a hyphen.
*
* NB: Component handlers must implement an initialize() method that will be called
* before the request is routed. The initialization method must enforce authorization
* and request validation.
*/
// The string to be found in the URL to mark this request as a component request
define('COMPONENT_ROUTER_PATHINFO_MARKER', '$$$call$$$');
// The parameter to be found in the query string for servers with path info disabled
define('COMPONENT_ROUTER_PARAMETER_MARKER', 'component');
// This is the maximum directory depth allowed within the component directory. Set
// it to something reasonable to avoid DoS or overflow attacks
define ('COMPONENT_ROUTER_PARTS_MAXDEPTH', 9);
// This is the maximum/minimum length of the name of a sub-directory or
// handler class name.
define ('COMPONENT_ROUTER_PARTS_MAXLENGTH', 50);
define ('COMPONENT_ROUTER_PARTS_MINLENGTH', 2);
import('lib.pkp.classes.core.PKPRouter');
import('classes.core.Request');
class PKPComponentRouter extends PKPRouter {
//
// Internal state cache variables
// NB: Please do not access directly but
// only via their respective getters/setters
//
/** @var string the requested component handler */
var $_component;
/** @var string the requested operation */
var $_op;
/** @var array the rpc service endpoint parts from the request */
var $_rpcServiceEndpointParts = false;
/** @var callable the rpc service endpoint the request was routed to */
var $_rpcServiceEndpoint = false;
/**
* Determines whether this router can route the given request.
* @param $request PKPRequest
* @return boolean true, if the router supports this request, otherwise false
*/
function supports($request) {
// See whether this looks like a component router request.
// NOTE: this is prone to false positives i.e. when a class
// name cannot be matched, but this laxity permits plugins to
// extend the system by registering against the
// LoadComponentHandler hook.
return $this->_retrieveServiceEndpointParts($request) !== null;
}
/**
* Retrieve the requested component from the request.
*
* NB: This can be a component that not actually exists
* in the code base.
*
* @param $request PKPRequest
* @return string the requested component or an empty string
* if none can be found.
*/
function getRequestedComponent($request) {
if (is_null($this->_component)) {
$this->_component = '';
// Retrieve the service endpoint parts from the request.
if (is_null($rpcServiceEndpointParts = $this->_getValidatedServiceEndpointParts($request))) {
// Endpoint parts cannot be found in the request
return '';
}
// Pop off the operation part
array_pop($rpcServiceEndpointParts);
// Construct the fully qualified component class name from the rest of it.
$handlerClassName = PKPString::camelize(array_pop($rpcServiceEndpointParts), CAMEL_CASE_HEAD_UP).'Handler';
// camelize remaining endpoint parts
$camelizedRpcServiceEndpointParts = array();
foreach ( $rpcServiceEndpointParts as $part) {
$camelizedRpcServiceEndpointParts[] = PKPString::camelize($part, CAMEL_CASE_HEAD_DOWN);
}
$handlerPackage = implode('.', $camelizedRpcServiceEndpointParts);
$this->_component = $handlerPackage.'.'.$handlerClassName;
}
return $this->_component;
}
/**
* Retrieve the requested operation from the request
*
* NB: This can be an operation that not actually
* exists in the requested component.
*
* @param $request PKPRequest
* @return string the requested operation or an empty string
* if none can be found.
*/
function getRequestedOp($request) {
if (is_null($this->_op)) {
$this->_op = '';
// Retrieve the service endpoint parts from the request.
if (is_null($rpcServiceEndpointParts = $this->_getValidatedServiceEndpointParts($request))) {
// Endpoint parts cannot be found in the request
return '';
}
// Pop off the operation part
$this->_op = PKPString::camelize(array_pop($rpcServiceEndpointParts), CAMEL_CASE_HEAD_DOWN);
}
return $this->_op;
}
/**
* Get the (validated) RPC service endpoint from the request.
* If no such RPC service endpoint can be constructed then the method
* returns null.
* @param $request PKPRequest the request to be routed
* @return callable an array with the handler instance
* and the handler operation to be called by call_user_func().
*/
function &getRpcServiceEndpoint($request) {
if ($this->_rpcServiceEndpoint === false) {
// We have not yet resolved this request. Mark the
// state variable so that we don't try again next
// time.
$this->_rpcServiceEndpoint = $nullVar = null;
// Retrieve requested component operation
$op = $this->getRequestedOp($request);
assert(!empty($op));
//
// Component Handler
//
// Retrieve requested component handler
$component = $this->getRequestedComponent($request);
$allowedPackages = null;
// Give plugins a chance to intervene
if (!HookRegistry::call('LoadComponentHandler', array(&$component, &$op))) {
if (empty($component)) return $nullVar;
// Construct the component handler file name and test its existence.
$component = 'controllers.'.$component;
$componentFileName = str_replace('.', DIRECTORY_SEPARATOR, $component).'.inc.php';
switch (true) {
case file_exists($componentFileName):
break;
case file_exists(PKP_LIB_PATH . DIRECTORY_SEPARATOR . $componentFileName):
$component = 'lib.pkp.'.$component;
break;
default:
// Request to non-existent handler
return $nullVar;
}
// We expect the handler to be part of one
// of the following packages:
$allowedPackages = array(
'controllers',
'lib.pkp.controllers'
);
}
// A handler at least needs to implement the
// following methods:
$requiredMethods = array(
$op, 'authorize', 'validate', 'initialize'
);
$componentInstance =& instantiate($component, 'PKPHandler', $allowedPackages, $requiredMethods);
if (!is_object($componentInstance)) return $nullVar;
$this->setHandler($componentInstance);
//
// Callable service endpoint
//
// Construct the callable array
$this->_rpcServiceEndpoint = array($componentInstance, $op);
}
return $this->_rpcServiceEndpoint;
}
//
// Implement template methods from PKPRouter
//
/**
* @copydoc PKPRouter::route()
*/
function route($request) {
// Determine the requested service endpoint.
$rpcServiceEndpoint =& $this->getRpcServiceEndpoint($request);
// Retrieve RPC arguments from the request.
$args = $request->getUserVars();
assert(is_array($args));
// Remove the caller-parameter (if present)
if (isset($args[COMPONENT_ROUTER_PARAMETER_MARKER])) unset($args[COMPONENT_ROUTER_PARAMETER_MARKER]);
// Authorize, validate and initialize the request
$this->_authorizeInitializeAndCallRequest($rpcServiceEndpoint, $request, $args);
}
/**
* @copydoc PKPRouter::url()
*/
function url($request, $newContext = null, $component = null, $op = null, $path = null,
$params = null, $anchor = null, $escape = false) {
if (!is_null($path)) {
throw new Exception('Path must be null when calling PKPComponentRouter::url()');
}
$pathInfoEnabled = $request->isPathInfoEnabled();
//
// Base URL and Context
//
$baseUrlAndContext = $this->_urlGetBaseAndContext(
$request, $this->_urlCanonicalizeNewContext($newContext));
$baseUrl = array_shift($baseUrlAndContext);
$context = $baseUrlAndContext;
//
// Component and Operation
//
// We only support component/op retrieval from the request
// if this request is a component request.
$currentRequestIsAComponentRequest = is_a($request->getRouter(), 'PKPComponentRouter');
if($currentRequestIsAComponentRequest) {
if (empty($component)) $component = $this->getRequestedComponent($request);
if (empty($op)) $op = $this->getRequestedOp($request);
}
assert(!empty($component) && !empty($op));
// Encode the component and operation
$componentParts = explode('.', $component);
$componentName = array_pop($componentParts);
assert(substr($componentName, -7) == 'Handler');
$componentName = PKPString::uncamelize(substr($componentName, 0, -7));
// uncamelize the component parts
$uncamelizedComponentParts = array();
foreach ($componentParts as $part) {
$uncamelizedComponentParts[] = PKPString::uncamelize($part);
}
array_push($uncamelizedComponentParts, $componentName);
$opName = PKPString::uncamelize($op);
//
// Additional query parameters
//
$additionalParameters = $this->_urlGetAdditionalParameters($request, $params, $escape);
//
// Anchor
//
$anchor = (empty($anchor) ? '' : '#'.rawurlencode($anchor));
//
// Assemble URL
//
if ($pathInfoEnabled) {
// If path info is enabled then context, page,
// operation and additional path go into the
// path info.
$pathInfoArray = array_merge(
$context,
array(COMPONENT_ROUTER_PATHINFO_MARKER),
$uncamelizedComponentParts,
array($opName)
);
// Query parameters
$queryParametersArray = $additionalParameters;
} else {
// If path info is disabled then context, page,
// operation and additional path are encoded as
// query parameters.
$pathInfoArray = array();
// Query parameters
$queryParametersArray = array_merge(
$context,
array(
COMPONENT_ROUTER_PARAMETER_MARKER.'='.implode('.', $uncamelizedComponentParts),
"op=$opName"
),
$additionalParameters
);
}
return $this->_urlFromParts($baseUrl, $pathInfoArray, $queryParametersArray, $anchor, $escape);
}
/**
* @copydoc PKPRouter::handleAuthorizationFailure()
*/
function handleAuthorizationFailure(
$request,
$authorizationMessage,
array $messageParams = []
) {
// Translate the authorization error message.
if (defined('LOCALE_COMPONENT_APP_COMMON')) {
AppLocale::requireComponents(LOCALE_COMPONENT_APP_COMMON);
}
AppLocale::requireComponents(LOCALE_COMPONENT_PKP_USER);
$translatedAuthorizationMessage = __($authorizationMessage, $messageParams);
// Add the router name and operation if show_stacktrace is enabled.
if (Config::getVar('debug', 'show_stacktrace')) {
$url = $request->getRequestUrl();
$queryString = $request->getQueryString();
if ($queryString) $queryString = '?'.$queryString;
$translatedAuthorizationMessage .= ' ['.$url.$queryString.']';
}
// Return a JSON error message.
import('lib.pkp.classes.core.JSONMessage');
return new JSONMessage(false, $translatedAuthorizationMessage);
}
//
// Private helper methods
//
/**
* Get the (validated) RPC service endpoint parts from the request.
* If no such RPC service endpoint parts can be retrieved
* then the method returns null.
* @param $request PKPRequest the request to be routed
* @return array a string array with the RPC service endpoint
* parts as values.
*/
function _getValidatedServiceEndpointParts($request) {
if ($this->_rpcServiceEndpointParts === false) {
// Mark the internal state variable so this
// will not be called again.
$this->_rpcServiceEndpointParts = null;
// Retrieve service endpoint parts from the request.
if (is_null($rpcServiceEndpointParts = $this->_retrieveServiceEndpointParts($request))) {
// This is not an RPC request
return null;
}
// Validate the service endpoint parts.
if (is_null($rpcServiceEndpointParts = $this->_validateServiceEndpointParts($rpcServiceEndpointParts))) {
// Invalid request
return null;
}
// Assign the validated service endpoint parts
$this->_rpcServiceEndpointParts = $rpcServiceEndpointParts;
}
return $this->_rpcServiceEndpointParts;
}
/**
* Try to retrieve a (non-validated) array with the service
* endpoint parts from the request. See the classdoc for the
* URL patterns supported here.
* @param $request PKPRequest the request to be routed
* @return array an array of (non-validated) service endpoint
* parts or null if the request is not an RPC request.
*/
function _retrieveServiceEndpointParts($request) {
// URL pattern depends on whether the server has path info
// enabled or not. See classdoc for details.
if ($request->isPathInfoEnabled()) {
if (!isset($_SERVER['PATH_INFO'])) return null;
$pathInfoParts = explode('/', trim($_SERVER['PATH_INFO'], '/'));
// We expect at least the context + the component
// router marker + 3 component parts (path, handler, operation)
$application = $this->getApplication();
$contextDepth = $application->getContextDepth();
if (count($pathInfoParts) < $contextDepth + 4) {
// This path info is too short to be an RPC request
return null;
}
// Check the component router marker
if ($pathInfoParts[$contextDepth] != COMPONENT_ROUTER_PATHINFO_MARKER) {
// This is not an RPC request
return null;
}
// Remove context and component marker from the array
$rpcServiceEndpointParts = array_slice($pathInfoParts, $contextDepth + 1);
} else {
$componentParameter = $request->getUserVar(COMPONENT_ROUTER_PARAMETER_MARKER);
$operationParameter = $request->getUserVar('op');
if (is_null($componentParameter) || is_null($operationParameter)) {
// This is not an RPC request
return null;
}
// Expand the router parameter
$rpcServiceEndpointParts = explode('.', $componentParameter);
// Add the operation
array_push($rpcServiceEndpointParts, $operationParameter);
}
return $rpcServiceEndpointParts;
}
/**
* This method pre-validates the service endpoint parts before
* we try to convert them to a file/method name. This also
* converts all parts to lower case.
* @param $rpcServiceEndpointParts array
* @return array the validated service endpoint parts or null if validation
* does not succeed.
*/
function _validateServiceEndpointParts($rpcServiceEndpointParts) {
// Do we have data at all?
if (is_null($rpcServiceEndpointParts) || empty($rpcServiceEndpointParts)
|| !is_array($rpcServiceEndpointParts)) return null;
// We require at least three parts: component directory, handler
// and method name.
if (count($rpcServiceEndpointParts) < 3) return null;
// Check that the array dimensions remain within sane limits.
if (count($rpcServiceEndpointParts) > COMPONENT_ROUTER_PARTS_MAXDEPTH) return null;
// Validate the individual endpoint parts.
foreach($rpcServiceEndpointParts as $key => $rpcServiceEndpointPart) {
// Make sure that none of the elements exceeds the length limit.
$partLen = strlen($rpcServiceEndpointPart);
if ($partLen > COMPONENT_ROUTER_PARTS_MAXLENGTH
|| $partLen < COMPONENT_ROUTER_PARTS_MINLENGTH) return null;
// Service endpoint URLs are case insensitive.
$rpcServiceEndpointParts[$key] = strtolower_codesafe($rpcServiceEndpointPart);
// We only allow letters, numbers and the hyphen.
if (!PKPString::regexp_match('/^[a-z0-9-]*$/', $rpcServiceEndpointPart)) return null;
}
return $rpcServiceEndpointParts;
}
}
|