Merge pull request #666 from ArthurHoaro/slim-api
REST API structure using Slim framework
This commit is contained in:
commit
80677a23e2
23 changed files with 1126 additions and 19 deletions
4
.htaccess
Normal file
4
.htaccess
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^ index.php [QSA,L]
|
|
@ -8,8 +8,6 @@ php:
|
||||||
- 7.0
|
- 7.0
|
||||||
- 5.6
|
- 5.6
|
||||||
- 5.5
|
- 5.5
|
||||||
- 5.4
|
|
||||||
- 5.3
|
|
||||||
install:
|
install:
|
||||||
- composer self-update
|
- composer self-update
|
||||||
- composer install --prefer-dist
|
- composer install --prefer-dist
|
||||||
|
|
|
@ -7,8 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - UNPUBLISHED
|
## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - UNPUBLISHED
|
||||||
|
|
||||||
|
**WARNING**: Shaarli now requires PHP 5.5+.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- REST API: see [Shaarli API documentation](http://shaarli.github.io/api-documentation/)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -256,6 +256,29 @@ public function updateMethodDatastoreIds()
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize API settings:
|
||||||
|
* - api.enabled: true
|
||||||
|
* - api.secret: generated secret
|
||||||
|
*/
|
||||||
|
public function updateMethodApiSettings()
|
||||||
|
{
|
||||||
|
if ($this->conf->exists('api.secret')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->conf->set('api.enabled', true);
|
||||||
|
$this->conf->set(
|
||||||
|
'api.secret',
|
||||||
|
generate_api_secret(
|
||||||
|
$this->conf->get('credentials.login'),
|
||||||
|
$this->conf->get('credentials.salt')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$this->conf->write($this->isLoggedIn);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -231,3 +231,29 @@ function autoLocale($headerLocale)
|
||||||
}
|
}
|
||||||
setlocale(LC_ALL, $attempts);
|
setlocale(LC_ALL, $attempts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a default API secret.
|
||||||
|
*
|
||||||
|
* Note that the random-ish methods used in this function are predictable,
|
||||||
|
* which makes them NOT suitable for crypto.
|
||||||
|
* BUT the random string is salted with the salt and hashed with the username.
|
||||||
|
* It makes the generated API secret secured enough for Shaarli.
|
||||||
|
*
|
||||||
|
* PHP 7 provides random_int(), designed for cryptography.
|
||||||
|
* More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
|
||||||
|
|
||||||
|
* @param string $username Shaarli login username
|
||||||
|
* @param string $salt Shaarli password hash salt
|
||||||
|
*
|
||||||
|
* @return string|bool Generated API secret, 12 char length.
|
||||||
|
* Or false if invalid parameters are provided (which will make the API unusable).
|
||||||
|
*/
|
||||||
|
function generate_api_secret($username, $salt)
|
||||||
|
{
|
||||||
|
if (empty($username) || empty($salt)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
|
||||||
|
}
|
||||||
|
|
132
application/api/ApiMiddleware.php
Normal file
132
application/api/ApiMiddleware.php
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
|
use Shaarli\Api\Exceptions\ApiException;
|
||||||
|
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||||
|
use Slim\Container;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiMiddleware
|
||||||
|
*
|
||||||
|
* This will be called before accessing any API Controller.
|
||||||
|
* Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
|
||||||
|
*
|
||||||
|
* If the request is validated, the controller is called, otherwise a JSON error response is returned.
|
||||||
|
*
|
||||||
|
* @package Api
|
||||||
|
*/
|
||||||
|
class ApiMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int JWT token validity in seconds (9 min).
|
||||||
|
*/
|
||||||
|
public static $TOKEN_DURATION = 540;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Container: contains conf, plugins, etc.
|
||||||
|
*/
|
||||||
|
protected $container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \ConfigManager instance.
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiMiddleware constructor.
|
||||||
|
*
|
||||||
|
* @param Container $container instance.
|
||||||
|
*/
|
||||||
|
public function __construct($container)
|
||||||
|
{
|
||||||
|
$this->container = $container;
|
||||||
|
$this->conf = $this->container->get('conf');
|
||||||
|
$this->setLinkDb($this->conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware execution:
|
||||||
|
* - check the API request
|
||||||
|
* - execute the controller
|
||||||
|
* - return the response
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request
|
||||||
|
* @param Response $response Slim response
|
||||||
|
* @param callable $next Next action
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*/
|
||||||
|
public function __invoke($request, $response, $next)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->checkRequest($request);
|
||||||
|
$response = $next($request, $response);
|
||||||
|
} catch(ApiException $e) {
|
||||||
|
$e->setResponse($response);
|
||||||
|
$e->setDebug($this->conf->get('dev.debug', false));
|
||||||
|
$response = $e->getApiResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the request validity (HTTP method, request value, etc.),
|
||||||
|
* that the API is enabled, and the JWT token validity.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request
|
||||||
|
*
|
||||||
|
* @throws ApiAuthorizationException The API is disabled or the token is invalid.
|
||||||
|
*/
|
||||||
|
protected function checkRequest($request)
|
||||||
|
{
|
||||||
|
if (! $this->conf->get('api.enabled', true)) {
|
||||||
|
throw new ApiAuthorizationException('API is disabled');
|
||||||
|
}
|
||||||
|
$this->checkToken($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the JWT token is set and valid.
|
||||||
|
* The API secret setting must be set.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request
|
||||||
|
*
|
||||||
|
* @throws ApiAuthorizationException The token couldn't be validated.
|
||||||
|
*/
|
||||||
|
protected function checkToken($request) {
|
||||||
|
$jwt = $request->getHeaderLine('jwt');
|
||||||
|
if (empty($jwt)) {
|
||||||
|
throw new ApiAuthorizationException('JWT token not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->conf->get('api.secret'))) {
|
||||||
|
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiUtils::validateJwtToken($jwt, $this->conf->get('api.secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a new LinkDB including private links,
|
||||||
|
* and load in the Slim container.
|
||||||
|
*
|
||||||
|
* FIXME! LinkDB could use a refactoring to avoid this trick.
|
||||||
|
*
|
||||||
|
* @param \ConfigManager $conf instance.
|
||||||
|
*/
|
||||||
|
protected function setLinkDb($conf)
|
||||||
|
{
|
||||||
|
$linkDb = new \LinkDB(
|
||||||
|
$conf->get('resource.datastore'),
|
||||||
|
true,
|
||||||
|
$conf->get('privacy.hide_public_links'),
|
||||||
|
$conf->get('redirector.url'),
|
||||||
|
$conf->get('redirector.encode_url')
|
||||||
|
);
|
||||||
|
$this->container['db'] = $linkDb;
|
||||||
|
}
|
||||||
|
}
|
51
application/api/ApiUtils.php
Normal file
51
application/api/ApiUtils.php
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
|
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiUtils
|
||||||
|
*
|
||||||
|
* Utility functions for the API.
|
||||||
|
*/
|
||||||
|
class ApiUtils
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validates a JWT token authenticity.
|
||||||
|
*
|
||||||
|
* @param string $token JWT token extracted from the headers.
|
||||||
|
* @param string $secret API secret set in the settings.
|
||||||
|
*
|
||||||
|
* @throws ApiAuthorizationException the token is not valid.
|
||||||
|
*/
|
||||||
|
public static function validateJwtToken($token, $secret)
|
||||||
|
{
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
|
||||||
|
throw new ApiAuthorizationException('Malformed JWT token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$genSign = hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret);
|
||||||
|
if ($parts[2] != $genSign) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = json_decode(base64_decode($parts[0]));
|
||||||
|
if ($header === null) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT header');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode(base64_decode($parts[1]));
|
||||||
|
if ($payload === null) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($payload->iat)
|
||||||
|
|| $payload->iat > time()
|
||||||
|
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
|
||||||
|
) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT issued time');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
54
application/api/controllers/ApiController.php
Normal file
54
application/api/controllers/ApiController.php
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use \Slim\Container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract Class ApiController
|
||||||
|
*
|
||||||
|
* Defines REST API Controller dependencies injected from the container.
|
||||||
|
*
|
||||||
|
* @package Api\Controllers
|
||||||
|
*/
|
||||||
|
abstract class ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Container
|
||||||
|
*/
|
||||||
|
protected $ci;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \ConfigManager
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \LinkDB
|
||||||
|
*/
|
||||||
|
protected $linkDb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int|null JSON style option.
|
||||||
|
*/
|
||||||
|
protected $jsonStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiController constructor.
|
||||||
|
*
|
||||||
|
* Note: enabling debug mode displays JSON with readable formatting.
|
||||||
|
*
|
||||||
|
* @param Container $ci Slim container.
|
||||||
|
*/
|
||||||
|
public function __construct(Container $ci)
|
||||||
|
{
|
||||||
|
$this->ci = $ci;
|
||||||
|
$this->conf = $ci->get('conf');
|
||||||
|
$this->linkDb = $ci->get('db');
|
||||||
|
if ($this->conf->get('dev.debug', false)) {
|
||||||
|
$this->jsonStyle = JSON_PRETTY_PRINT;
|
||||||
|
} else {
|
||||||
|
$this->jsonStyle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
application/api/controllers/Info.php
Normal file
42
application/api/controllers/Info.php
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Info
|
||||||
|
*
|
||||||
|
* REST API Controller: /info
|
||||||
|
*
|
||||||
|
* @package Api\Controllers
|
||||||
|
* @see http://shaarli.github.io/api-documentation/#links-instance-information-get
|
||||||
|
*/
|
||||||
|
class Info extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Service providing various information about Shaarli instance.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*/
|
||||||
|
public function getInfo($request, $response)
|
||||||
|
{
|
||||||
|
$info = [
|
||||||
|
'global_counter' => count($this->linkDb),
|
||||||
|
'private_counter' => count_private($this->linkDb),
|
||||||
|
'settings' => array(
|
||||||
|
'title' => $this->conf->get('general.title', 'Shaarli'),
|
||||||
|
'header_link' => $this->conf->get('general.header_link', '?'),
|
||||||
|
'timezone' => $this->conf->get('general.timezone', 'UTC'),
|
||||||
|
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
|
||||||
|
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $response->withJson($info, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
}
|
34
application/api/exceptions/ApiAuthorizationException.php
Normal file
34
application/api/exceptions/ApiAuthorizationException.php
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiAuthorizationException
|
||||||
|
*
|
||||||
|
* Request not authorized, return a 401 HTTP code.
|
||||||
|
*/
|
||||||
|
class ApiAuthorizationException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
$this->setMessage('Not authorized');
|
||||||
|
return $this->buildApiResponse(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the exception message.
|
||||||
|
*
|
||||||
|
* We only return a generic error message in production mode to avoid giving
|
||||||
|
* to much security information.
|
||||||
|
*
|
||||||
|
* @param $message string the exception message.
|
||||||
|
*/
|
||||||
|
public function setMessage($message)
|
||||||
|
{
|
||||||
|
$original = $this->debug === true ? ': '. $this->getMessage() : '';
|
||||||
|
$this->message = $message . $original;
|
||||||
|
}
|
||||||
|
}
|
19
application/api/exceptions/ApiBadParametersException.php
Normal file
19
application/api/exceptions/ApiBadParametersException.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiBadParametersException
|
||||||
|
*
|
||||||
|
* Invalid request exception, return a 400 HTTP code.
|
||||||
|
*/
|
||||||
|
class ApiBadParametersException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
return $this->buildApiResponse(400);
|
||||||
|
}
|
||||||
|
}
|
77
application/api/exceptions/ApiException.php
Normal file
77
application/api/exceptions/ApiException.php
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class ApiException
|
||||||
|
*
|
||||||
|
* Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
|
||||||
|
* Also can include various information in debug mode.
|
||||||
|
*/
|
||||||
|
abstract class ApiException extends \Exception {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Response instance from Slim.
|
||||||
|
*/
|
||||||
|
protected $response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Debug mode enabled/disabled.
|
||||||
|
*/
|
||||||
|
protected $debug;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the final response.
|
||||||
|
*
|
||||||
|
* @return Response Final response to give.
|
||||||
|
*/
|
||||||
|
public abstract function getApiResponse();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates ApiResponse body.
|
||||||
|
* In production mode, it will only return the exception message,
|
||||||
|
* but in dev mode, it includes additional information in an array.
|
||||||
|
*
|
||||||
|
* @return array|string response body
|
||||||
|
*/
|
||||||
|
protected function getApiResponseBody() {
|
||||||
|
if ($this->debug !== true) {
|
||||||
|
return $this->getMessage();
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Response object to return.
|
||||||
|
*
|
||||||
|
* @param int $code HTTP status.
|
||||||
|
*
|
||||||
|
* @return Response with status + body.
|
||||||
|
*/
|
||||||
|
protected function buildApiResponse($code)
|
||||||
|
{
|
||||||
|
$style = $this->debug ? JSON_PRETTY_PRINT : null;
|
||||||
|
return $this->response->withJson($this->getApiResponseBody(), $code, $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Response $response
|
||||||
|
*/
|
||||||
|
public function setResponse($response)
|
||||||
|
{
|
||||||
|
$this->response = $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $debug
|
||||||
|
*/
|
||||||
|
public function setDebug($debug)
|
||||||
|
{
|
||||||
|
$this->debug = $debug;
|
||||||
|
}
|
||||||
|
}
|
19
application/api/exceptions/ApiInternalException.php
Normal file
19
application/api/exceptions/ApiInternalException.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiInternalException
|
||||||
|
*
|
||||||
|
* Generic exception, return a 500 HTTP code.
|
||||||
|
*/
|
||||||
|
class ApiInternalException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
return $this->buildApiResponse(500);
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,8 @@ class ConfigManager
|
||||||
*/
|
*/
|
||||||
protected static $NOT_FOUND = 'NOT_FOUND';
|
protected static $NOT_FOUND = 'NOT_FOUND';
|
||||||
|
|
||||||
|
public static $DEFAULT_PLUGINS = array('qrcode');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Config folder.
|
* @var string Config folder.
|
||||||
*/
|
*/
|
||||||
|
@ -308,7 +310,7 @@ protected function setDefaultValues()
|
||||||
|
|
||||||
$this->setEmpty('general.header_link', '?');
|
$this->setEmpty('general.header_link', '?');
|
||||||
$this->setEmpty('general.links_per_page', 20);
|
$this->setEmpty('general.links_per_page', 20);
|
||||||
$this->setEmpty('general.enabled_plugins', array('qrcode'));
|
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
|
||||||
|
|
||||||
$this->setEmpty('updates.check_updates', false);
|
$this->setEmpty('updates.check_updates', false);
|
||||||
$this->setEmpty('updates.check_updates_branch', 'stable');
|
$this->setEmpty('updates.check_updates_branch', 'stable');
|
||||||
|
|
|
@ -10,14 +10,22 @@
|
||||||
},
|
},
|
||||||
"keywords": ["bookmark", "link", "share", "web"],
|
"keywords": ["bookmark", "link", "share", "web"],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=5.3.4",
|
"php": ">=5.5",
|
||||||
"shaarli/netscape-bookmark-parser": "1.*",
|
"shaarli/netscape-bookmark-parser": "1.*",
|
||||||
"erusev/parsedown": "1.6"
|
"erusev/parsedown": "1.6",
|
||||||
|
"slim/slim": "^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpmd/phpmd" : "@stable",
|
"phpmd/phpmd" : "@stable",
|
||||||
"phpunit/phpunit": "4.8.*",
|
"phpunit/phpunit": "4.8.*",
|
||||||
"sebastian/phpcpd": "*",
|
"sebastian/phpcpd": "*",
|
||||||
"squizlabs/php_codesniffer": "2.*"
|
"squizlabs/php_codesniffer": "2.*"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Shaarli\\Api\\": "application/api/",
|
||||||
|
"Shaarli\\Api\\Controllers\\": "application/api/controllers",
|
||||||
|
"Shaarli\\Api\\Exceptions\\": "application/api/exceptions"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
56
index.php
56
index.php
|
@ -175,7 +175,6 @@ function stripslashes_deep($value) { $value = is_array($value) ? array_map('stri
|
||||||
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
|
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
|
||||||
autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']);
|
autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']);
|
||||||
}
|
}
|
||||||
header('Content-Type: text/html; charset=utf-8'); // We use UTF-8 for proper international characters handling.
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checking session state (i.e. is the user still logged in)
|
* Checking session state (i.e. is the user still logged in)
|
||||||
|
@ -731,17 +730,10 @@ function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager) {
|
||||||
*
|
*
|
||||||
* @param ConfigManager $conf Configuration Manager instance.
|
* @param ConfigManager $conf Configuration Manager instance.
|
||||||
* @param PluginManager $pluginManager Plugin Manager instance,
|
* @param PluginManager $pluginManager Plugin Manager instance,
|
||||||
|
* @param LinkDB $LINKSDB
|
||||||
*/
|
*/
|
||||||
function renderPage($conf, $pluginManager)
|
function renderPage($conf, $pluginManager, $LINKSDB)
|
||||||
{
|
{
|
||||||
$LINKSDB = new LinkDB(
|
|
||||||
$conf->get('resource.datastore'),
|
|
||||||
isLoggedIn(),
|
|
||||||
$conf->get('privacy.hide_public_links'),
|
|
||||||
$conf->get('redirector.url'),
|
|
||||||
$conf->get('redirector.encode_url')
|
|
||||||
);
|
|
||||||
|
|
||||||
$updater = new Updater(
|
$updater = new Updater(
|
||||||
read_updates_file($conf->get('resource.updates')),
|
read_updates_file($conf->get('resource.updates')),
|
||||||
$LINKSDB,
|
$LINKSDB,
|
||||||
|
@ -938,7 +930,7 @@ function renderPage($conf, $pluginManager)
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display openseach plugin (XML)
|
// Display opensearch plugin (XML)
|
||||||
if ($targetPage == Router::$PAGE_OPENSEARCH) {
|
if ($targetPage == Router::$PAGE_OPENSEARCH) {
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
$PAGE->assign('serverurl', index_url($_SERVER));
|
$PAGE->assign('serverurl', index_url($_SERVER));
|
||||||
|
@ -1142,6 +1134,8 @@ function renderPage($conf, $pluginManager)
|
||||||
$conf->set('feed.rss_permalinks', !empty($_POST['enableRssPermalinks']));
|
$conf->set('feed.rss_permalinks', !empty($_POST['enableRssPermalinks']));
|
||||||
$conf->set('updates.check_updates', !empty($_POST['updateCheck']));
|
$conf->set('updates.check_updates', !empty($_POST['updateCheck']));
|
||||||
$conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks']));
|
$conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks']));
|
||||||
|
$conf->set('api.enabled', !empty($_POST['apiEnabled']));
|
||||||
|
$conf->set('api.secret', escape($_POST['apiSecret']));
|
||||||
try {
|
try {
|
||||||
$conf->write(isLoggedIn());
|
$conf->write(isLoggedIn());
|
||||||
}
|
}
|
||||||
|
@ -1170,6 +1164,8 @@ function renderPage($conf, $pluginManager)
|
||||||
$PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false));
|
$PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false));
|
||||||
$PAGE->assign('enable_update_check', $conf->get('updates.check_updates', true));
|
$PAGE->assign('enable_update_check', $conf->get('updates.check_updates', true));
|
||||||
$PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
|
$PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
|
||||||
|
$PAGE->assign('api_enabled', $conf->get('api.enabled', true));
|
||||||
|
$PAGE->assign('api_secret', $conf->get('api.secret'));
|
||||||
$PAGE->renderPage('configure');
|
$PAGE->renderPage('configure');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
@ -1954,6 +1950,14 @@ function install($conf)
|
||||||
$conf->set('general.title', 'Shared links on '.escape(index_url($_SERVER)));
|
$conf->set('general.title', 'Shared links on '.escape(index_url($_SERVER)));
|
||||||
}
|
}
|
||||||
$conf->set('updates.check_updates', !empty($_POST['updateCheck']));
|
$conf->set('updates.check_updates', !empty($_POST['updateCheck']));
|
||||||
|
$conf->set('api.enabled', !empty($_POST['enableApi']));
|
||||||
|
$conf->set(
|
||||||
|
'api.secret',
|
||||||
|
generate_api_secret(
|
||||||
|
$this->conf->get('credentials.login'),
|
||||||
|
$this->conf->get('credentials.salt')
|
||||||
|
)
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Everything is ok, let's create config file.
|
// Everything is ok, let's create config file.
|
||||||
$conf->write(isLoggedIn());
|
$conf->write(isLoggedIn());
|
||||||
|
@ -2216,4 +2220,32 @@ function resizeImage($filepath)
|
||||||
if (!isset($_SESSION['LINKS_PER_PAGE'])) {
|
if (!isset($_SESSION['LINKS_PER_PAGE'])) {
|
||||||
$_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
|
$_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
|
||||||
}
|
}
|
||||||
renderPage($conf, $pluginManager);
|
|
||||||
|
$linkDb = new LinkDB(
|
||||||
|
$conf->get('resource.datastore'),
|
||||||
|
isLoggedIn(),
|
||||||
|
$conf->get('privacy.hide_public_links'),
|
||||||
|
$conf->get('redirector.url'),
|
||||||
|
$conf->get('redirector.encode_url')
|
||||||
|
);
|
||||||
|
|
||||||
|
$container = new \Slim\Container();
|
||||||
|
$container['conf'] = $conf;
|
||||||
|
$container['plugins'] = $pluginManager;
|
||||||
|
$app = new \Slim\App($container);
|
||||||
|
|
||||||
|
// REST API routes
|
||||||
|
$app->group('/api/v1', function() {
|
||||||
|
$this->get('/info', '\Api\Controllers\Info:getInfo');
|
||||||
|
})->add('\Api\ApiMiddleware');
|
||||||
|
|
||||||
|
$response = $app->run(true);
|
||||||
|
// Hack to make Slim and Shaarli router work together:
|
||||||
|
// If a Slim route isn't found, we call renderPage().
|
||||||
|
if ($response->getStatusCode() == 404) {
|
||||||
|
// We use UTF-8 for proper international characters handling.
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
renderPage($conf, $pluginManager, $linkDb);
|
||||||
|
} else {
|
||||||
|
$app->respond($response);
|
||||||
|
}
|
||||||
|
|
|
@ -271,7 +271,7 @@ public function testConfigToJsonNothingToDo()
|
||||||
public function testEscapeConfig()
|
public function testEscapeConfig()
|
||||||
{
|
{
|
||||||
$sandbox = 'sandbox/config';
|
$sandbox = 'sandbox/config';
|
||||||
copy(self::$configFile .'.json.php', $sandbox .'.json.php');
|
copy(self::$configFile . '.json.php', $sandbox . '.json.php');
|
||||||
$this->conf = new ConfigManager($sandbox);
|
$this->conf = new ConfigManager($sandbox);
|
||||||
$title = '<script>alert("title");</script>';
|
$title = '<script>alert("title");</script>';
|
||||||
$headerLink = '<script>alert("header_link");</script>';
|
$headerLink = '<script>alert("header_link");</script>';
|
||||||
|
@ -286,7 +286,43 @@ public function testEscapeConfig()
|
||||||
$this->assertEquals(escape($title), $this->conf->get('general.title'));
|
$this->assertEquals(escape($title), $this->conf->get('general.title'));
|
||||||
$this->assertEquals(escape($headerLink), $this->conf->get('general.header_link'));
|
$this->assertEquals(escape($headerLink), $this->conf->get('general.header_link'));
|
||||||
$this->assertEquals(escape($redirectorUrl), $this->conf->get('redirector.url'));
|
$this->assertEquals(escape($redirectorUrl), $this->conf->get('redirector.url'));
|
||||||
unlink($sandbox .'.json.php');
|
unlink($sandbox . '.json.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test updateMethodApiSettings(): create default settings for the API (enabled + secret).
|
||||||
|
*/
|
||||||
|
public function testUpdateApiSettings()
|
||||||
|
{
|
||||||
|
$confFile = 'sandbox/config';
|
||||||
|
copy(self::$configFile .'.json.php', $confFile .'.json.php');
|
||||||
|
$conf = new ConfigManager($confFile);
|
||||||
|
$updater = new Updater(array(), array(), $conf, true);
|
||||||
|
|
||||||
|
$this->assertFalse($conf->exists('api.enabled'));
|
||||||
|
$this->assertFalse($conf->exists('api.secret'));
|
||||||
|
$updater->updateMethodApiSettings();
|
||||||
|
$conf->reload();
|
||||||
|
$this->assertTrue($conf->get('api.enabled'));
|
||||||
|
$this->assertTrue($conf->exists('api.secret'));
|
||||||
|
unlink($confFile .'.json.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test updateMethodApiSettings(): already set, do nothing.
|
||||||
|
*/
|
||||||
|
public function testUpdateApiSettingsNothingToDo()
|
||||||
|
{
|
||||||
|
$confFile = 'sandbox/config';
|
||||||
|
copy(self::$configFile .'.json.php', $confFile .'.json.php');
|
||||||
|
$conf = new ConfigManager($confFile);
|
||||||
|
$conf->set('api.enabled', false);
|
||||||
|
$conf->set('api.secret', '');
|
||||||
|
$updater = new Updater(array(), array(), $conf, true);
|
||||||
|
$updater->updateMethodApiSettings();
|
||||||
|
$this->assertFalse($conf->get('api.enabled'));
|
||||||
|
$this->assertEmpty($conf->get('api.secret'));
|
||||||
|
unlink($confFile .'.json.php');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -253,4 +253,21 @@ public function testIsSessionIdInvalid()
|
||||||
is_session_id_valid('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=')
|
is_session_id_valid('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test generateSecretApi.
|
||||||
|
*/
|
||||||
|
public function testGenerateSecretApi()
|
||||||
|
{
|
||||||
|
$this->assertEquals(12, strlen(generate_api_secret('foo', 'bar')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test generateSecretApi with invalid parameters.
|
||||||
|
*/
|
||||||
|
public function testGenerateSecretApiInvalid()
|
||||||
|
{
|
||||||
|
$this->assertFalse(generate_api_secret('', ''));
|
||||||
|
$this->assertFalse(generate_api_secret(false, false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
184
tests/api/ApiMiddlewareTest.php
Normal file
184
tests/api/ApiMiddlewareTest.php
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
|
use Slim\Container;
|
||||||
|
use Slim\Http\Environment;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiMiddlewareTest
|
||||||
|
*
|
||||||
|
* Test the REST API Slim Middleware.
|
||||||
|
*
|
||||||
|
* Note that we can't test a valid use case here, because the middleware
|
||||||
|
* needs to call a valid controller/action during its execution.
|
||||||
|
*
|
||||||
|
* @package Api
|
||||||
|
*/
|
||||||
|
class ApiMiddlewareTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string datastore to test write operations
|
||||||
|
*/
|
||||||
|
protected static $testDatastore = 'sandbox/datastore.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \ConfigManager instance
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \ReferenceLinkDB instance.
|
||||||
|
*/
|
||||||
|
protected $refDB = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Container instance.
|
||||||
|
*/
|
||||||
|
protected $container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before every test, instantiate a new Api with its config, plugins and links.
|
||||||
|
*/
|
||||||
|
public function setUp()
|
||||||
|
{
|
||||||
|
$this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
|
||||||
|
$this->conf->set('api.secret', 'NapoleonWasALizard');
|
||||||
|
|
||||||
|
$this->refDB = new \ReferenceLinkDB();
|
||||||
|
$this->refDB->write(self::$testDatastore);
|
||||||
|
|
||||||
|
$this->container = new Container();
|
||||||
|
$this->container['conf'] = $this->conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After every test, remove the test datastore.
|
||||||
|
*/
|
||||||
|
public function tearDown()
|
||||||
|
{
|
||||||
|
@unlink(self::$testDatastore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the middleware with the API disabled:
|
||||||
|
* should return a 401 error Unauthorized.
|
||||||
|
*/
|
||||||
|
public function testInvokeMiddlewareApiDisabled()
|
||||||
|
{
|
||||||
|
$this->conf->set('api.enabled', false);
|
||||||
|
$mw = new ApiMiddleware($this->container);
|
||||||
|
$env = Environment::mock([
|
||||||
|
'REQUEST_METHOD' => 'GET',
|
||||||
|
'REQUEST_URI' => '/echo',
|
||||||
|
]);
|
||||||
|
$request = Request::createFromEnvironment($env);
|
||||||
|
$response = new Response();
|
||||||
|
/** @var Response $response */
|
||||||
|
$response = $mw($request, $response, null);
|
||||||
|
|
||||||
|
$this->assertEquals(401, $response->getStatusCode());
|
||||||
|
$body = json_decode((string) $response->getBody());
|
||||||
|
$this->assertEquals('Not authorized', $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the middleware with the API disabled in debug mode:
|
||||||
|
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
|
||||||
|
*/
|
||||||
|
public function testInvokeMiddlewareApiDisabledDebug()
|
||||||
|
{
|
||||||
|
$this->conf->set('api.enabled', false);
|
||||||
|
$this->conf->set('dev.debug', true);
|
||||||
|
$mw = new ApiMiddleware($this->container);
|
||||||
|
$env = Environment::mock([
|
||||||
|
'REQUEST_METHOD' => 'GET',
|
||||||
|
'REQUEST_URI' => '/echo',
|
||||||
|
]);
|
||||||
|
$request = Request::createFromEnvironment($env);
|
||||||
|
$response = new Response();
|
||||||
|
/** @var Response $response */
|
||||||
|
$response = $mw($request, $response, null);
|
||||||
|
|
||||||
|
$this->assertEquals(401, $response->getStatusCode());
|
||||||
|
$body = json_decode((string) $response->getBody());
|
||||||
|
$this->assertEquals('Not authorized: API is disabled', $body->message);
|
||||||
|
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the middleware without a token (debug):
|
||||||
|
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
|
||||||
|
*/
|
||||||
|
public function testInvokeMiddlewareNoTokenProvidedDebug()
|
||||||
|
{
|
||||||
|
$this->conf->set('dev.debug', true);
|
||||||
|
$mw = new ApiMiddleware($this->container);
|
||||||
|
$env = Environment::mock([
|
||||||
|
'REQUEST_METHOD' => 'GET',
|
||||||
|
'REQUEST_URI' => '/echo',
|
||||||
|
]);
|
||||||
|
$request = Request::createFromEnvironment($env);
|
||||||
|
$response = new Response();
|
||||||
|
/** @var Response $response */
|
||||||
|
$response = $mw($request, $response, null);
|
||||||
|
|
||||||
|
$this->assertEquals(401, $response->getStatusCode());
|
||||||
|
$body = json_decode((string) $response->getBody());
|
||||||
|
$this->assertEquals('Not authorized: JWT token not provided', $body->message);
|
||||||
|
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the middleware without a secret set in settings (debug):
|
||||||
|
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
|
||||||
|
*/
|
||||||
|
public function testInvokeMiddlewareNoSecretSetDebug()
|
||||||
|
{
|
||||||
|
$this->conf->set('dev.debug', true);
|
||||||
|
$this->conf->set('api.secret', '');
|
||||||
|
$mw = new ApiMiddleware($this->container);
|
||||||
|
$env = Environment::mock([
|
||||||
|
'REQUEST_METHOD' => 'GET',
|
||||||
|
'REQUEST_URI' => '/echo',
|
||||||
|
'HTTP_JWT'=> 'jwt',
|
||||||
|
]);
|
||||||
|
$request = Request::createFromEnvironment($env);
|
||||||
|
$response = new Response();
|
||||||
|
/** @var Response $response */
|
||||||
|
$response = $mw($request, $response, null);
|
||||||
|
|
||||||
|
$this->assertEquals(401, $response->getStatusCode());
|
||||||
|
$body = json_decode((string) $response->getBody());
|
||||||
|
$this->assertEquals('Not authorized: Token secret must be set in Shaarli\'s administration', $body->message);
|
||||||
|
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the middleware without an invalid JWT token (debug):
|
||||||
|
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
|
||||||
|
*
|
||||||
|
* Note: specific JWT errors tests are handled in ApiUtilsTest.
|
||||||
|
*/
|
||||||
|
public function testInvokeMiddlewareInvalidJwtDebug()
|
||||||
|
{
|
||||||
|
$this->conf->set('dev.debug', true);
|
||||||
|
$mw = new ApiMiddleware($this->container);
|
||||||
|
$env = Environment::mock([
|
||||||
|
'REQUEST_METHOD' => 'GET',
|
||||||
|
'REQUEST_URI' => '/echo',
|
||||||
|
'HTTP_JWT'=> 'bad jwt',
|
||||||
|
]);
|
||||||
|
$request = Request::createFromEnvironment($env);
|
||||||
|
$response = new Response();
|
||||||
|
/** @var Response $response */
|
||||||
|
$response = $mw($request, $response, null);
|
||||||
|
|
||||||
|
$this->assertEquals(401, $response->getStatusCode());
|
||||||
|
$body = json_decode((string) $response->getBody());
|
||||||
|
$this->assertEquals('Not authorized: Malformed JWT token', $body->message);
|
||||||
|
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
|
||||||
|
}
|
||||||
|
}
|
206
tests/api/ApiUtilsTest.php
Normal file
206
tests/api/ApiUtilsTest.php
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiUtilsTest
|
||||||
|
*/
|
||||||
|
class ApiUtilsTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Force the timezone for ISO datetimes.
|
||||||
|
*/
|
||||||
|
public static function setUpBeforeClass()
|
||||||
|
{
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a valid JWT token.
|
||||||
|
*
|
||||||
|
* @param string $secret API secret used to generate the signature.
|
||||||
|
*
|
||||||
|
* @return string Generated token.
|
||||||
|
*/
|
||||||
|
public static function generateValidJwtToken($secret)
|
||||||
|
{
|
||||||
|
$header = base64_encode('{
|
||||||
|
"typ": "JWT",
|
||||||
|
"alg": "HS512"
|
||||||
|
}');
|
||||||
|
$payload = base64_encode('{
|
||||||
|
"iat": '. time() .'
|
||||||
|
}');
|
||||||
|
$signature = hash_hmac('sha512', $header .'.'. $payload , $secret);
|
||||||
|
return $header .'.'. $payload .'.'. $signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token from given header and payload.
|
||||||
|
*
|
||||||
|
* @param string $header Header in JSON format.
|
||||||
|
* @param string $payload Payload in JSON format.
|
||||||
|
* @param string $secret API secret used to hash the signature.
|
||||||
|
*
|
||||||
|
* @return string JWT token.
|
||||||
|
*/
|
||||||
|
public static function generateCustomJwtToken($header, $payload, $secret)
|
||||||
|
{
|
||||||
|
$header = base64_encode($header);
|
||||||
|
$payload = base64_encode($payload);
|
||||||
|
$signature = hash_hmac('sha512', $header . '.' . $payload, $secret);
|
||||||
|
return $header . '.' . $payload . '.' . $signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a valid JWT token.
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenValid()
|
||||||
|
{
|
||||||
|
$secret = 'WarIsPeace';
|
||||||
|
ApiUtils::validateJwtToken(self::generateValidJwtToken($secret), $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a malformed JWT token.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Malformed JWT token
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenMalformed()
|
||||||
|
{
|
||||||
|
$token = 'ABC.DEF';
|
||||||
|
ApiUtils::validateJwtToken($token, 'foo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with an empty JWT token.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Malformed JWT token
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenMalformedEmpty()
|
||||||
|
{
|
||||||
|
$token = false;
|
||||||
|
ApiUtils::validateJwtToken($token, 'foo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token without header.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Malformed JWT token
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenMalformedEmptyHeader()
|
||||||
|
{
|
||||||
|
$token = '.payload.signature';
|
||||||
|
ApiUtils::validateJwtToken($token, 'foo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token without payload
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Malformed JWT token
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenMalformedEmptyPayload()
|
||||||
|
{
|
||||||
|
$token = 'header..signature';
|
||||||
|
ApiUtils::validateJwtToken($token, 'foo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token with an empty signature.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT signature
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidSignatureEmpty()
|
||||||
|
{
|
||||||
|
$token = 'header.payload.';
|
||||||
|
ApiUtils::validateJwtToken($token, 'foo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token with an invalid signature.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT signature
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidSignature()
|
||||||
|
{
|
||||||
|
$token = 'header.payload.nope';
|
||||||
|
ApiUtils::validateJwtToken($token, 'foo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token with a signature generated with the wrong API secret.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT signature
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidSignatureSecret()
|
||||||
|
{
|
||||||
|
ApiUtils::validateJwtToken(self::generateValidJwtToken('foo'), 'bar');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token with a an invalid header (not JSON).
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT header
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidHeader()
|
||||||
|
{
|
||||||
|
$token = $this->generateCustomJwtToken('notJSON', '{"JSON":1}', 'secret');
|
||||||
|
ApiUtils::validateJwtToken($token, 'secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token with a an invalid payload (not JSON).
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT payload
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidPayload()
|
||||||
|
{
|
||||||
|
$token = $this->generateCustomJwtToken('{"JSON":1}', 'notJSON', 'secret');
|
||||||
|
ApiUtils::validateJwtToken($token, 'secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token without issued time.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT issued time
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidTimeEmpty()
|
||||||
|
{
|
||||||
|
$token = $this->generateCustomJwtToken('{"JSON":1}', '{"JSON":1}', 'secret');
|
||||||
|
ApiUtils::validateJwtToken($token, 'secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with an expired JWT token.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT issued time
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidTimeExpired()
|
||||||
|
{
|
||||||
|
$token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() - 600) . '}', 'secret');
|
||||||
|
ApiUtils::validateJwtToken($token, 'secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validateJwtToken() with a JWT token issued in the future.
|
||||||
|
*
|
||||||
|
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
|
||||||
|
* @expectedExceptionMessage Invalid JWT issued time
|
||||||
|
*/
|
||||||
|
public function testValidateJwtTokenInvalidTimeFuture()
|
||||||
|
{
|
||||||
|
$token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret');
|
||||||
|
ApiUtils::validateJwtToken($token, 'secret');
|
||||||
|
}
|
||||||
|
}
|
113
tests/api/controllers/InfoTest.php
Normal file
113
tests/api/controllers/InfoTest.php
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Slim\Container;
|
||||||
|
use Slim\Http\Environment;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class InfoTest
|
||||||
|
*
|
||||||
|
* Test REST API controller Info.
|
||||||
|
*
|
||||||
|
* @package Api\Controllers
|
||||||
|
*/
|
||||||
|
class InfoTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string datastore to test write operations
|
||||||
|
*/
|
||||||
|
protected static $testDatastore = 'sandbox/datastore.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \ConfigManager instance
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \ReferenceLinkDB instance.
|
||||||
|
*/
|
||||||
|
protected $refDB = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Container instance.
|
||||||
|
*/
|
||||||
|
protected $container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Info controller instance.
|
||||||
|
*/
|
||||||
|
protected $controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before every test, instantiate a new Api with its config, plugins and links.
|
||||||
|
*/
|
||||||
|
public function setUp()
|
||||||
|
{
|
||||||
|
$this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
|
||||||
|
$this->refDB = new \ReferenceLinkDB();
|
||||||
|
$this->refDB->write(self::$testDatastore);
|
||||||
|
|
||||||
|
$this->container = new Container();
|
||||||
|
$this->container['conf'] = $this->conf;
|
||||||
|
$this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
|
||||||
|
|
||||||
|
$this->controller = new Info($this->container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After every test, remove the test datastore.
|
||||||
|
*/
|
||||||
|
public function tearDown()
|
||||||
|
{
|
||||||
|
@unlink(self::$testDatastore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test /info service.
|
||||||
|
*/
|
||||||
|
public function testGetInfo()
|
||||||
|
{
|
||||||
|
$env = Environment::mock([
|
||||||
|
'REQUEST_METHOD' => 'GET',
|
||||||
|
]);
|
||||||
|
$request = Request::createFromEnvironment($env);
|
||||||
|
|
||||||
|
$response = $this->controller->getInfo($request, new Response());
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
$data = json_decode((string) $response->getBody(), true);
|
||||||
|
|
||||||
|
$this->assertEquals(8, $data['global_counter']);
|
||||||
|
$this->assertEquals(2, $data['private_counter']);
|
||||||
|
$this->assertEquals('Shaarli', $data['settings']['title']);
|
||||||
|
$this->assertEquals('?', $data['settings']['header_link']);
|
||||||
|
$this->assertEquals('UTC', $data['settings']['timezone']);
|
||||||
|
$this->assertEquals(\ConfigManager::$DEFAULT_PLUGINS, $data['settings']['enabled_plugins']);
|
||||||
|
$this->assertEquals(false, $data['settings']['default_private_links']);
|
||||||
|
|
||||||
|
$title = 'My links';
|
||||||
|
$headerLink = 'http://shaarli.tld';
|
||||||
|
$timezone = 'Europe/Paris';
|
||||||
|
$enabledPlugins = array('foo', 'bar');
|
||||||
|
$defaultPrivateLinks = true;
|
||||||
|
$this->conf->set('general.title', $title);
|
||||||
|
$this->conf->set('general.header_link', $headerLink);
|
||||||
|
$this->conf->set('general.timezone', $timezone);
|
||||||
|
$this->conf->set('general.enabled_plugins', $enabledPlugins);
|
||||||
|
$this->conf->set('privacy.default_private_links', $defaultPrivateLinks);
|
||||||
|
|
||||||
|
$response = $this->controller->getInfo($request, new Response());
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
$data = json_decode((string) $response->getBody(), true);
|
||||||
|
|
||||||
|
$this->assertEquals(8, $data['global_counter']);
|
||||||
|
$this->assertEquals(2, $data['private_counter']);
|
||||||
|
$this->assertEquals($title, $data['settings']['title']);
|
||||||
|
$this->assertEquals($headerLink, $data['settings']['header_link']);
|
||||||
|
$this->assertEquals($timezone, $data['settings']['timezone']);
|
||||||
|
$this->assertEquals($enabledPlugins, $data['settings']['enabled_plugins']);
|
||||||
|
$this->assertEquals($defaultPrivateLinks, $data['settings']['default_private_links']);
|
||||||
|
}
|
||||||
|
}
|
|
@ -80,6 +80,20 @@
|
||||||
<label for="updateCheck"> Notify me when a new release is ready</label>
|
<label for="updateCheck"> Notify me when a new release is ready</label>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top"><b>Enable REST API</b></td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="apiEnabled" id="apiEnabled"
|
||||||
|
{if="$api_enabled"}checked{/if}/>
|
||||||
|
<label for="apiEnabled"> Allow third party software to use Shaarli such as mobile application.</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top"><b>API secret</b></td>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="apiSecret" id="apiSecret" size="50" value="{$api_secret}" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
|
|
@ -14,6 +14,18 @@ <h1>Shaarli</h1>
|
||||||
<tr><td valign="top"><b>Update:</b></td><td>
|
<tr><td valign="top"><b>Update:</b></td><td>
|
||||||
<input type="checkbox" name="updateCheck" id="updateCheck" checked="checked"><label for="updateCheck"> Notify me when a new release is ready</label></td>
|
<input type="checkbox" name="updateCheck" id="updateCheck" checked="checked"><label for="updateCheck"> Notify me when a new release is ready</label></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
<b>API:</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="enableApi" id="enableApi" checked="checked">
|
||||||
|
<label for="enableApi">
|
||||||
|
Enable Shaarli's REST API.
|
||||||
|
Allow third party software to use Shaarli such as mobile application.
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr><td colspan="2"><input type="submit" name="Save" value="Save config" class="bigbutton"></td></tr>
|
<tr><td colspan="2"><input type="submit" name="Save" value="Save config" class="bigbutton"></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
|
|
Loading…
Reference in a new issue