diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..66ef8f6 --- /dev/null +++ b/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5a85e..fe775b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added +- REST API: see [Shaarli API documentation](http://shaarli.github.io/api-documentation/) + ### Changed ### Fixed diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php new file mode 100644 index 0000000..162e88e --- /dev/null +++ b/application/api/ApiMiddleware.php @@ -0,0 +1,132 @@ +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; + } +} diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php new file mode 100644 index 0000000..fbb1e72 --- /dev/null +++ b/application/api/ApiUtils.php @@ -0,0 +1,51 @@ +iat) + || $payload->iat > time() + || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION + ) { + throw new ApiAuthorizationException('Invalid JWT issued time'); + } + } +} diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php new file mode 100644 index 0000000..1dd47f1 --- /dev/null +++ b/application/api/controllers/ApiController.php @@ -0,0 +1,54 @@ +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; + } + } +} diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php new file mode 100644 index 0000000..25433f7 --- /dev/null +++ b/application/api/controllers/Info.php @@ -0,0 +1,42 @@ + 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); + } +} diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php new file mode 100644 index 0000000..0e3f477 --- /dev/null +++ b/application/api/exceptions/ApiAuthorizationException.php @@ -0,0 +1,34 @@ +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; + } +} diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php new file mode 100644 index 0000000..e5cc19e --- /dev/null +++ b/application/api/exceptions/ApiBadParametersException.php @@ -0,0 +1,19 @@ +buildApiResponse(400); + } +} diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php new file mode 100644 index 0000000..c8490e0 --- /dev/null +++ b/application/api/exceptions/ApiException.php @@ -0,0 +1,77 @@ +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; + } +} diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php new file mode 100644 index 0000000..1cb0553 --- /dev/null +++ b/application/api/exceptions/ApiInternalException.php @@ -0,0 +1,19 @@ +buildApiResponse(500); + } +} diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index f5f753f..ca8918b 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -20,6 +20,8 @@ class ConfigManager */ protected static $NOT_FOUND = 'NOT_FOUND'; + public static $DEFAULT_PLUGINS = array('qrcode'); + /** * @var string Config folder. */ @@ -308,7 +310,7 @@ class ConfigManager $this->setEmpty('general.header_link', '?'); $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_branch', 'stable'); diff --git a/composer.json b/composer.json index 40b725d..4786fe9 100644 --- a/composer.json +++ b/composer.json @@ -12,12 +12,20 @@ "require": { "php": ">=5.5", "shaarli/netscape-bookmark-parser": "1.*", - "erusev/parsedown": "1.6" + "erusev/parsedown": "1.6", + "slim/slim": "^3.0" }, "require-dev": { "phpmd/phpmd" : "@stable", "phpunit/phpunit": "4.8.*", "sebastian/phpcpd": "*", "squizlabs/php_codesniffer": "2.*" + }, + "autoload": { + "psr-4": { + "Shaarli\\Api\\": "application/api/", + "Shaarli\\Api\\Controllers\\": "application/api/controllers", + "Shaarli\\Api\\Exceptions\\": "application/api/exceptions" + } } } diff --git a/index.php b/index.php index 25e37b3..835fd7d 100644 --- a/index.php +++ b/index.php @@ -175,7 +175,6 @@ define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['R if (isset($_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) @@ -731,17 +730,10 @@ function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager) { * * @param ConfigManager $conf Configuration 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( read_updates_file($conf->get('resource.updates')), $LINKSDB, @@ -938,7 +930,7 @@ function renderPage($conf, $pluginManager) exit; } - // Display openseach plugin (XML) + // Display opensearch plugin (XML) if ($targetPage == Router::$PAGE_OPENSEARCH) { header('Content-Type: application/xml; charset=utf-8'); $PAGE->assign('serverurl', index_url($_SERVER)); @@ -2226,4 +2218,32 @@ if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do= if (!isset($_SESSION['LINKS_PER_PAGE'])) { $_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); +} diff --git a/tests/api/ApiMiddlewareTest.php b/tests/api/ApiMiddlewareTest.php new file mode 100644 index 0000000..4d4dd9b --- /dev/null +++ b/tests/api/ApiMiddlewareTest.php @@ -0,0 +1,184 @@ +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); + } +} diff --git a/tests/api/ApiUtilsTest.php b/tests/api/ApiUtilsTest.php new file mode 100644 index 0000000..10da145 --- /dev/null +++ b/tests/api/ApiUtilsTest.php @@ -0,0 +1,206 @@ +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'); + } +} diff --git a/tests/api/controllers/InfoTest.php b/tests/api/controllers/InfoTest.php new file mode 100644 index 0000000..2916eed --- /dev/null +++ b/tests/api/controllers/InfoTest.php @@ -0,0 +1,113 @@ +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']); + } +} diff --git a/tpl/configure.html b/tpl/configure.html index a015770..b4197bf 100644 --- a/tpl/configure.html +++ b/tpl/configure.html @@ -81,7 +81,7 @@ - Enable API + Enable REST API diff --git a/tpl/install.html b/tpl/install.html index eda4c54..42874dc 100644 --- a/tpl/install.html +++ b/tpl/install.html @@ -21,7 +21,7 @@