535 lines
19 KiB
PHP
535 lines
19 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @file classes/core/PKPComponentRouter.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
|
|
* ".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.
|
|
*/
|
|
|
|
namespace PKP\core;
|
|
|
|
use Exception;
|
|
use PKP\config\Config;
|
|
use PKP\plugins\Hook;
|
|
|
|
// 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);
|
|
|
|
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 */
|
|
public $_component;
|
|
/** @var string the requested operation */
|
|
public $_op;
|
|
/** @var array the rpc service endpoint parts from the request */
|
|
public $_rpcServiceEndpointParts = false;
|
|
/** @var callable the rpc service endpoint the request was routed to */
|
|
public $_rpcServiceEndpoint = false;
|
|
|
|
|
|
/**
|
|
* Determines whether this router can route the given request.
|
|
*
|
|
* @param PKPRequest $request
|
|
*
|
|
* @return bool true, if the router supports this request, otherwise false
|
|
*/
|
|
public function supports($request): bool
|
|
{
|
|
// 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 PKPRequest $request
|
|
*
|
|
* @return string the requested component or an empty string
|
|
* if none can be found.
|
|
*/
|
|
public 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), PKPString::CAMEL_CASE_HEAD_UP) . 'Handler';
|
|
|
|
// camelize remaining endpoint parts
|
|
$camelizedRpcServiceEndpointParts = [];
|
|
foreach ($rpcServiceEndpointParts as $part) {
|
|
$camelizedRpcServiceEndpointParts[] = PKPString::camelize($part, PKPString::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 PKPRequest $request
|
|
*
|
|
* @return string the requested operation or an empty string
|
|
* if none can be found.
|
|
*/
|
|
public 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), PKPString::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 PKPRequest $request the request to be routed
|
|
*
|
|
* @return callable|array|null an array with the handler instance
|
|
* and the handler operation to be called by call_user_func().
|
|
*/
|
|
public 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);
|
|
$componentInstance = null;
|
|
$allowedPackages = null;
|
|
|
|
// Give plugins a chance to intervene
|
|
if (!Hook::call('LoadComponentHandler', [&$component, &$op, &$componentInstance])) {
|
|
if (empty($component)) {
|
|
return $nullVar;
|
|
}
|
|
|
|
// Construct the component handler file name and test its existence.
|
|
$component = 'controllers.' . $component;
|
|
$componentFileNamePart = str_replace('.', '/', $component);
|
|
switch (true) {
|
|
case file_exists("{$componentFileNamePart}.php"):
|
|
$className = 'APP\\' . strtr($componentFileNamePart, '/', '\\');
|
|
$componentInstance = new $className();
|
|
break;
|
|
|
|
case file_exists("{$componentFileNamePart}.inc.php"):
|
|
// This behaviour is DEPRECATED as of 3.4.0.
|
|
break;
|
|
|
|
case file_exists(PKP_LIB_PATH . "/{$componentFileNamePart}.php"):
|
|
$className = 'PKP\\' . strtr($componentFileNamePart, '/', '\\');
|
|
$componentInstance = new $className();
|
|
break;
|
|
|
|
case file_exists(PKP_LIB_PATH . "/{$componentFileNamePart}.inc.php"):
|
|
// This behaviour is DEPRECATED as of 3.4.0.
|
|
$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 = [
|
|
'controllers',
|
|
'lib.pkp.controllers'
|
|
];
|
|
}
|
|
|
|
// A handler at least needs to implement the
|
|
// following methods:
|
|
$requiredMethods = [
|
|
$op, 'authorize', 'validate', 'initialize'
|
|
];
|
|
|
|
if (!$componentInstance) {
|
|
$componentInstance = instantiate($component, 'PKPHandler', $allowedPackages, $requiredMethods);
|
|
}
|
|
if (!is_object($componentInstance)) {
|
|
return $nullVar;
|
|
}
|
|
$this->setHandler($componentInstance);
|
|
|
|
//
|
|
// Callable service endpoint
|
|
//
|
|
// Construct the callable array
|
|
$this->_rpcServiceEndpoint = [$componentInstance, $op];
|
|
}
|
|
|
|
return $this->_rpcServiceEndpoint;
|
|
}
|
|
|
|
|
|
//
|
|
// Implement template methods from PKPRouter
|
|
//
|
|
/**
|
|
* @copydoc PKPRouter::route()
|
|
*/
|
|
public 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()
|
|
*
|
|
* @param null|mixed $newContext
|
|
* @param null|mixed $component
|
|
* @param null|mixed $op
|
|
* @param null|mixed $path
|
|
* @param null|mixed $params
|
|
* @param null|mixed $anchor
|
|
*/
|
|
public 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()');
|
|
}
|
|
|
|
//
|
|
// Base URL and Context
|
|
//
|
|
[$baseUrl, $context] = $this->_urlGetBaseAndContext($request, $newContext);
|
|
|
|
//
|
|
// Component and Operation
|
|
//
|
|
// We only support component/op retrieval from the request
|
|
// if this request is a component request.
|
|
$currentRequestIsAComponentRequest = $request->getRouter() instanceof self;
|
|
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 = [];
|
|
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
|
|
//
|
|
// Context, page, operation and additional path go into the path info.
|
|
$pathInfoArray = array_merge(
|
|
$context,
|
|
[COMPONENT_ROUTER_PATHINFO_MARKER],
|
|
$uncamelizedComponentParts,
|
|
[$opName]
|
|
);
|
|
|
|
// Query parameters
|
|
$queryParametersArray = $additionalParameters;
|
|
|
|
return $this->_urlFromParts($baseUrl, $pathInfoArray, $queryParametersArray, $anchor, $escape);
|
|
}
|
|
|
|
/**
|
|
* @copydoc PKPRouter::handleAuthorizationFailure()
|
|
*/
|
|
public function handleAuthorizationFailure(
|
|
$request,
|
|
$authorizationMessage,
|
|
array $messageParams = []
|
|
) {
|
|
$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.
|
|
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 PKPRequest $request the request to be routed
|
|
*
|
|
* @return ?array a string array with the RPC service endpoint
|
|
* parts as values.
|
|
*/
|
|
public 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 PKPRequest $request 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.
|
|
*/
|
|
public function _retrieveServiceEndpointParts($request)
|
|
{
|
|
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();
|
|
if (count($pathInfoParts) < 5) {
|
|
// This path info is too short to be an RPC request
|
|
return null;
|
|
}
|
|
|
|
// Check the component router marker
|
|
if ($pathInfoParts[1] != COMPONENT_ROUTER_PATHINFO_MARKER) {
|
|
// This is not an RPC request
|
|
return null;
|
|
}
|
|
|
|
// Remove context and component marker from the array
|
|
$rpcServiceEndpointParts = array_slice($pathInfoParts, 2);
|
|
|
|
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 array $rpcServiceEndpointParts
|
|
*
|
|
* @return ?array the validated service endpoint parts or null if validation
|
|
* does not succeed.
|
|
*/
|
|
public 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;
|
|
}
|
|
}
|
|
|
|
if (!PKP_STRICT_MODE) {
|
|
class_alias('\PKP\core\PKPComponentRouter', '\PKPComponentRouter');
|
|
}
|