<?php
/**
* @file lib/pkp/classes/handler/APIHandler.inc.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2003-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class APIHandler
* @ingroup handler
*
* @brief Base request API handler
*/
AppLocale::requireComponents(LOCALE_COMPONENT_PKP_API, LOCALE_COMPONENT_APP_API);
import('lib.pkp.classes.handler.PKPHandler');
use \Slim\App;
import('lib.pkp.classes.core.APIResponse');
import('classes.core.Services');
class APIHandler extends PKPHandler {
protected $_app;
protected $_request;
protected $_endpoints = array();
protected $_slimRequest = null;
/** @var string The endpoint pattern for this handler */
protected $_pathPattern;
/** @var string The unique endpoint string for this handler */
protected $_handlerPath = null;
/**
* Constructor
*/
public function __construct() {
parent::__construct();
import('lib.pkp.classes.security.authorization.internal.ApiAuthorizationMiddleware');
import('lib.pkp.classes.security.authorization.internal.ApiTokenDecodingMiddleware');
import('lib.pkp.classes.security.authorization.internal.ApiCsrfMiddleware');
$this->_app = new \Slim\App(array(
// Load custom response handler
'response' => function($c) {
return new APIResponse();
},
'settings' => array(
// we need access to route within middleware
'determineRouteBeforeAppMiddleware' => true,
)
));
$this->_app->add(new ApiAuthorizationMiddleware($this));
$this->_app->add(new ApiCsrfMiddleware($this));
$this->_app->add(new ApiTokenDecodingMiddleware($this));
// remove trailing slashes
$this->_app->add(function ($request, $response, $next) {
$uri = $request->getUri();
$path = $uri->getPath();
if ($path != '/' && substr($path, -1) == '/') {
// path with trailing slashes to non-trailing counterpart
$uri = $uri->withPath(substr($path, 0, -1));
if($request->getMethod() == 'GET') {
return $response->withRedirect((string)$uri, 301);
} else {
return $next($request->withUri($uri), $response);
}
}
return $next($request, $response);
});
// if pathinfo is disabled, rewrite URI to match Slim's expectation
$app = $this->getApp();
$handler = $this;
$this->_app->add(function($request, $response, $next) use($app, $handler) {
$uri = $request->getUri();
$endpoint = trim($request->getQueryParam('endpoint'));
$pathInfoEnabled = Config::getVar('general', 'disable_path_info') ? false : true;
$path = $uri->getPath();
if (!$pathInfoEnabled && !is_null($endpoint) && !isset($_SERVER['PATH_INFO']) && ($path == '/')) {
$basePath = $uri->getBasePath();
if($request->getMethod() == 'GET') {
$uri = $uri->withPath($basePath . $endpoint);
return $response->withRedirect((string)$uri, 301);
} else {
/**
* WARNING
*
* We have to rewrite the URI in the Request object and then kick off
* the middleware again with $app->process(). Because PSR-7 Request
* objects are immutable, we can't just set the URI and move on. We
* need to reset our own copy of the Request ($this->_slimRequest) so
* that it is available for use by our Handlers (or anywhere outside
* of API middleware).
*
* This means that we should never seek the Request in our handler by
* going to Slim's container. A call to:
*
* $this->getApp()->getContainer()->get('request')
*
* Will not have the updated URI. Handlers should access their own
* stored copy:
*
* $this->getSlimRequest()
*
* This will solve the URI issue when path info is disabled. But if
* middleware updates the request, this stored copy will fall out of
* sync. This may lead to hard-to-debug issues. Whenever possible,
* middleware that modifies the request should reset the request in
* the handler to keep it synced.
*
* I hope, when you need it, you will find this message and it will
* save you the day that I just had.
*/
$uri = $uri->withPath($basePath . $endpoint);
$handler->_slimRequest = $request->withUri($uri);
return $app->process($handler->_slimRequest, $response);
}
} elseif ($pathInfoEnabled) {
// pkp/pkp-lib#4919: PKP software routes with PATH_INFO (unaffected by
// mod_rewrite) but Slim relies on REQUEST_URI. Inject PATH_INFO into
// Slim for consistent behavior in URL rewriting scenarios.
$newUri = $uri->withPath($_SERVER['PATH_INFO']);
if ($uri != $newUri) {
$handler->_slimRequest = $request->withUri($newUri);
return $app->process($handler->_slimRequest, $response);
}
}
return $next($request, $response);
});
// Allow remote requests to the API
$this->_app->add(function ($request, $response, $next) {
$response = $response->withHeader('Access-Control-Allow-Origin', '*');
return $next($request, $response);
});
$this->_request = Application::get()->getRequest();
$this->setupEndpoints();
}
/**
* Return PKP request object
*
* @return Request
*/
public function getRequest() {
return $this->_request;
}
/**
* Return Slim request object
*
* @return SlimRequest|null
*/
public function getSlimRequest() {
return $this->_slimRequest;
}
/**
* Set Slim request object
*
*/
public function setSlimRequest($slimRequest) {
return $this->_slimRequest = $slimRequest;
}
/**
* Get the Slim application.
* @return App
*/
public function getApp() {
return $this->_app;
}
/**
* Get the endpoint pattern for this handler
*
* Compiles the URI path pattern from the context, api version and the
* unique string for the this handler.
*
* @return string
*/
public function getEndpointPattern() {
if (!isset($this->_pathPattern)) {
$this->_pathPattern = '/{contextPath}/api/{version}/' . $this->_handlerPath;
}
return $this->_pathPattern;
}
/**
* Get the entity ID for a specified parameter name.
* (Parameter names are generally defined in authorization policies
* @return int|string?
*/
public function getEntityId($parameterName) {
assert(false);
return null;
}
/**
* setup endpoints
*/
public function setupEndpoints() {
$app = $this->getApp();
$endpoints = $this->getEndpoints();
HookRegistry::call('APIHandler::endpoints', [&$endpoints, $this]);
foreach ($endpoints as $method => $definitions) {
foreach ($definitions as $parameters) {
$method = strtolower($method);
$pattern = $parameters['pattern'];
$handler = $parameters['handler'];
$roles = isset($parameters['roles']) ? $parameters['roles'] : null;
$app->$method($pattern, $handler)->setName($handler[1]);
if (!is_null($roles) && is_array($roles)) {
$this->addRoleAssignment($roles, $handler[1]);
}
}
}
}
/**
* Returns the list of endpoints
*
* @return array
*/
public function getEndpoints() {
return $this->_endpoints;
}
/**
* Fetches parameter value
*
* @param string $parameterName
* @param mixed $default
*
* @return mixed
*/
public function getParameter($parameterName, $default = null) {
$slimRequest = $this->getSlimRequest();
if ($slimRequest == null) {
return $default;
}
$route = $slimRequest->getAttribute('route');
// we probably have an invalid url if route is null
if (!is_null($route)) {
$arguments = $route->getArguments();
if (isset($arguments[$parameterName])) {
return $arguments[$parameterName];
}
$queryParams = $slimRequest->getQueryParams();
if (isset($queryParams[$parameterName])) {
return $queryParams[$parameterName];
}
}
return $default;
}
/**
* Convert string values in boolean, integer and number parameters to their
* appropriate type when the string is in a recognizable format.
*
* Converted booleans: False: "0", "false". True: "true", "1"
* Converted integers: Anything that passes ctype_digit()
* Converted floats: Anything that passes is_numeric()
*
* Empty strings will be converted to null.
*
* @param $schema string One of the SCHEMA_... constants
* @param $params array Key/value parameters to be validated
* @return array Converted parameters
*/
public function convertStringsToSchema($schema, $params) {
$schema = Services::get('schema')->get($schema);
foreach ($params as $paramName => $paramValue) {
if (!property_exists($schema->properties, $paramName)) {
continue;
}
if (!empty($schema->properties->{$paramName}->multilingual)) {
foreach ($paramValue as $localeKey => $localeValue) {
$params[$paramName][$localeKey] = $this->_convertStringsToSchema(
$localeValue,
$schema->properties->{$paramName}->type,
$schema->properties->{$paramName}
);
}
} else {
$params[$paramName] = $this->_convertStringsToSchema(
$paramValue,
$schema->properties->{$paramName}->type,
$schema->properties->{$paramName}
);
}
}
return $params;
}
/**
* Helper function to convert a string to a specified type if it meets
* certain conditions.
*
* This function can be called recursively on nested objects and arrays.
*
* @see self::convertStringsToTypes
* @param $value
* @param $type One of boolean, integer or number
*/
private function _convertStringsToSchema($value, $type, $schema) {
// Convert all empty strings to null except arrays (see note below)
if (is_string($value) && !strlen($value) && $type !== 'array') {
return null;
}
switch ($type) {
case 'boolean':
if (is_string($value)) {
if ($value === 'true' || $value === '1') {
return true;
} elseif ($value === 'false' || $value === '0') {
return false;
}
}
break;
case 'integer':
if (is_string($value) && ctype_digit($value)) {
return (int) $value;
}
break;
case 'number':
if (is_string($value) && is_numeric($value)) {
return floatval($value);
}
break;
case 'array':
if (is_array($value)) {
$newArray = [];
if (is_array($schema->items)) {
foreach ($schema->items as $i => $itemSchema) {
$newArray[$i] = $this->_convertStringsToSchema($value[$i], $itemSchema->type, $itemSchema);
}
} else {
foreach ($value as $i => $v) {
$newArray[$i] = $this->_convertStringsToSchema($v, $schema->items->type, $schema->items);
}
}
return $newArray;
// An empty string is accepted as an empty array. This addresses the
// issue where browsers strip empty arrays from post data before sending.
// See: https://bugs.jquery.com/ticket/6481
} elseif (is_string($value) && !strlen($value)) {
return [];
}
break;
case 'object':
if (is_array($value)) {
$newObject = [];
foreach ($schema->properties as $propName => $propSchema) {
if (!isset($value[$propName])) {
continue;
}
$newObject[$propName] = $this->_convertStringsToSchema($value[$propName], $propSchema->type, $propSchema);
}
return $value;
}
break;
}
return $value;
}
/**
* A helper method to validate start and end date params for stats
* API handlers
*
* 1. Checks the date formats
* 2. Ensures a start date is not earlier than STATISTICS_EARLIEST_DATE
* 3. Ensures an end date is no later than yesterday
* 4. Ensures the start date is not later than the end date
*
* @param array $params The params to validate
* @param string $dateStartParam Where the find the start date in the array of params
* @param string $dateEndParam Where to find the end date in the array of params
* @return boolean|string True if they validate, or a string which
* contains the locale key of an error message.
*/
protected function _validateStatDates($params, $dateStartParam = 'dateStart', $dateEndParam = 'dateEnd') {
import('lib.pkp.classes.validation.ValidatorFactory');
$validator = \ValidatorFactory::make(
$params,
[
$dateStartParam => [
'date_format:Y-m-d',
'after_or_equal:' . STATISTICS_EARLIEST_DATE,
'before_or_equal:' . $dateEndParam,
],
$dateEndParam => [
'date_format:Y-m-d',
'before_or_equal:yesterday',
'after_or_equal:' . $dateStartParam,
],
],
[
'*.date_format' => 'invalidFormat',
$dateStartParam . '.after_or_equal' => 'tooEarly',
$dateEndParam . '.before_or_equal' => 'tooLate',
$dateStartParam . '.before_or_equal' => 'invalidRange',
$dateEndParam . '.after_or_equal' => 'invalidRange',
]
);
if ($validator->fails()) {
$errors = $validator->errors()->getMessages();
if ((!empty($errors[$dateStartParam]) && in_array('invalidFormat', $errors[$dateStartParam]))
|| (!empty($errors[$dateEndParam]) && in_array('invalidFormat', $errors[$dateEndParam]))) {
return 'api.stats.400.wrongDateFormat';
}
if (!empty($errors[$dateStartParam]) && in_array('tooEarly', $errors[$dateStartParam])) {
return 'api.stats.400.earlyDateRange';
}
if (!empty($errors[$dateEndParam]) && in_array('tooLate', $errors[$dateEndParam])) {
return 'api.stats.400.lateDateRange';
}
if ((!empty($errors[$dateStartParam]) && in_array('invalidRange', $errors[$dateStartParam]))
|| (!empty($errors[$dateEndParam]) && in_array('invalidRange', $errors[$dateEndParam]))) {
return 'api.stats.400.wrongDateRange';
}
}
return true;
}
}
|