From 624f999fb75ceeefbc690276f42e5a545ad35357 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Mon, 12 Dec 2016 03:51:48 +0100 Subject: [PATCH 001/121] Ignore compressed tar archive --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 095aade..9121905 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ composer.lock vendor/ # Release archives -*.tar +*.tar.gz *.zip # Development and test resources From cbfdcff2615e901bdc434d06f38a3da8eecbdf8b Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 31 Jul 2016 10:46:17 +0200 Subject: [PATCH 002/121] Prepare settings for the API in the admin page and during the install API settings: - api.enabled - api.secret The API settings will be initialized (and the secret generated) with an update method. --- application/Updater.php | 23 ++++++++++++++++++++ application/Utils.php | 26 +++++++++++++++++++++++ index.php | 12 +++++++++++ tests/Updater/UpdaterTest.php | 40 +++++++++++++++++++++++++++++++++-- tests/UtilsTest.php | 17 +++++++++++++++ tpl/configure.html | 14 ++++++++++++ tpl/install.html | 12 +++++++++++ 7 files changed, 142 insertions(+), 2 deletions(-) diff --git a/application/Updater.php b/application/Updater.php index f0d0281..38de335 100644 --- a/application/Updater.php +++ b/application/Updater.php @@ -256,6 +256,29 @@ class Updater 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; + } } /** diff --git a/application/Utils.php b/application/Utils.php index 0a5b476..6290234 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -231,3 +231,29 @@ function autoLocale($headerLocale) } 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)); +} diff --git a/index.php b/index.php index cc44835..25e37b3 100644 --- a/index.php +++ b/index.php @@ -1142,6 +1142,8 @@ function renderPage($conf, $pluginManager) $conf->set('feed.rss_permalinks', !empty($_POST['enableRssPermalinks'])); $conf->set('updates.check_updates', !empty($_POST['updateCheck'])); $conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks'])); + $conf->set('api.enabled', !empty($_POST['apiEnabled'])); + $conf->set('api.secret', escape($_POST['apiSecret'])); try { $conf->write(isLoggedIn()); } @@ -1170,6 +1172,8 @@ function renderPage($conf, $pluginManager) $PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false)); $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('api_enabled', $conf->get('api.enabled', true)); + $PAGE->assign('api_secret', $conf->get('api.secret')); $PAGE->renderPage('configure'); exit; } @@ -1952,6 +1956,14 @@ function install($conf) $conf->set('general.title', 'Shared links on '.escape(index_url($_SERVER))); } $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 { // Everything is ok, let's create config file. $conf->write(isLoggedIn()); diff --git a/tests/Updater/UpdaterTest.php b/tests/Updater/UpdaterTest.php index 4948fe5..0171daa 100644 --- a/tests/Updater/UpdaterTest.php +++ b/tests/Updater/UpdaterTest.php @@ -271,7 +271,7 @@ $GLOBALS[\'privateLinkByDefault\'] = true;'; public function testEscapeConfig() { $sandbox = 'sandbox/config'; - copy(self::$configFile .'.json.php', $sandbox .'.json.php'); + copy(self::$configFile . '.json.php', $sandbox . '.json.php'); $this->conf = new ConfigManager($sandbox); $title = ''; $headerLink = ''; @@ -286,7 +286,43 @@ $GLOBALS[\'privateLinkByDefault\'] = true;'; $this->assertEquals(escape($title), $this->conf->get('general.title')); $this->assertEquals(escape($headerLink), $this->conf->get('general.header_link')); $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'); } /** diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index 6a7870c..0cf9a92 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -253,4 +253,21 @@ class UtilsTest extends PHPUnit_Framework_TestCase 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)); + } } diff --git a/tpl/configure.html b/tpl/configure.html index 983bcd0..a015770 100644 --- a/tpl/configure.html +++ b/tpl/configure.html @@ -80,6 +80,20 @@ + + Enable API + + + + + + + API secret + + + + diff --git a/tpl/install.html b/tpl/install.html index 88eb540..eda4c54 100644 --- a/tpl/install.html +++ b/tpl/install.html @@ -14,6 +14,18 @@ Update: + + + API: + + + + + + From 423ab02846286f94276d21e38ca1e296646618bf Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 15 Dec 2016 10:04:05 +0100 Subject: [PATCH 003/121] PHP requirement increased to PHP 5.5 - See #599 --- .travis.yml | 2 -- CHANGELOG.md | 2 ++ composer.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9ffb3d0..88cc827 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,8 +7,6 @@ php: - 7.0 - 5.6 - 5.5 - - 5.4 - - 5.3 install: - composer self-update - composer install --prefer-dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d5436..cf5a85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - UNPUBLISHED +**WARNING**: Shaarli now requires PHP 5.5+. + ### Added ### Changed diff --git a/composer.json b/composer.json index f7d26a3..40b725d 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ }, "keywords": ["bookmark", "link", "share", "web"], "require": { - "php": ">=5.3.4", + "php": ">=5.5", "shaarli/netscape-bookmark-parser": "1.*", "erusev/parsedown": "1.6" }, From 18e6796726d73d7dc90ecdd16c181493941f5487 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 15 Dec 2016 10:13:00 +0100 Subject: [PATCH 004/121] REST API structure using Slim framework * REST API routes are handle by Slim. * Every API controller go through ApiMiddleware which handles security. * First service implemented `/info`, for tests purpose. --- .htaccess | 4 + CHANGELOG.md | 2 + application/api/ApiMiddleware.php | 132 +++++++++++ application/api/ApiUtils.php | 51 +++++ application/api/controllers/ApiController.php | 54 +++++ application/api/controllers/Info.php | 42 ++++ .../exceptions/ApiAuthorizationException.php | 34 +++ .../exceptions/ApiBadParametersException.php | 19 ++ application/api/exceptions/ApiException.php | 77 +++++++ .../api/exceptions/ApiInternalException.php | 19 ++ application/config/ConfigManager.php | 4 +- composer.json | 10 +- index.php | 44 +++- tests/api/ApiMiddlewareTest.php | 184 ++++++++++++++++ tests/api/ApiUtilsTest.php | 206 ++++++++++++++++++ tests/api/controllers/InfoTest.php | 113 ++++++++++ tpl/configure.html | 2 +- tpl/install.html | 2 +- 18 files changed, 983 insertions(+), 16 deletions(-) create mode 100644 .htaccess create mode 100644 application/api/ApiMiddleware.php create mode 100644 application/api/ApiUtils.php create mode 100644 application/api/controllers/ApiController.php create mode 100644 application/api/controllers/Info.php create mode 100644 application/api/exceptions/ApiAuthorizationException.php create mode 100644 application/api/exceptions/ApiBadParametersException.php create mode 100644 application/api/exceptions/ApiException.php create mode 100644 application/api/exceptions/ApiInternalException.php create mode 100644 tests/api/ApiMiddlewareTest.php create mode 100644 tests/api/ApiUtilsTest.php create mode 100644 tests/api/controllers/InfoTest.php 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 @@ From 4cfe8d330319d83c3024a4149afeca0138922fda Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 15 Dec 2016 10:57:11 +0100 Subject: [PATCH 005/121] Fixes can login function call in loginform.html Fixes #711 --- .travis.yml | 1 + tpl/loginform.html | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9ffb3d0..6ff1b20 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ cache: directories: - $HOME/.composer/cache php: + - 7.1 - 7.0 - 5.6 - 5.5 diff --git a/tpl/loginform.html b/tpl/loginform.html index a49b42d..8417638 100644 --- a/tpl/loginform.html +++ b/tpl/loginform.html @@ -2,7 +2,7 @@ {include="includes"} - {if="!ban_canLogin()"} + {if="!ban_canLogin($conf)"} You have been banned from login after too many failed attempts. Try later. {else}
From 826c6af7c011234d6e1666a2f7f25744fcf397b7 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 15 Dec 2016 11:18:56 +0100 Subject: [PATCH 006/121] Fix a regression: permalinks change when old links are edited fixes #713 --- index.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.php b/index.php index cc44835..a0a3a8c 100644 --- a/index.php +++ b/index.php @@ -1249,10 +1249,12 @@ function renderPage($conf, $pluginManager) // Edit $created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate); $updated = new DateTime(); + $shortUrl = $LINKSDB[$id]['shorturl']; } else { // New link $created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate); $updated = null; + $shortUrl = link_small_hash($created, $id); } // Remove multiple spaces. @@ -1279,7 +1281,7 @@ function renderPage($conf, $pluginManager) 'created' => $created, 'updated' => $updated, 'tags' => str_replace(',', ' ', $tags), - 'shorturl' => link_small_hash($created, $id), + 'shorturl' => $shortUrl, ); // If title is empty, use the URL as title. From f4ebd5fed20b29c4fb580983b4be7bd0a52151b9 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 5 Nov 2016 14:13:18 +0100 Subject: [PATCH 007/121] Bugfixes on link deletion, and use a GET form Use a GET form to delete links: harmonize with edit_link and preparation for #585 Bug fixes: * LinkDB element can't be passed as reference, fix error: PHP Notice: Indirect modification of overloaded element of LinkDB has no effect * Resource cache folder setting wasn't set correctly --- application/Router.php | 6 ++++++ index.php | 16 ++++++++-------- tpl/linklist.html | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/application/Router.php b/application/Router.php index caed4a2..c9a5191 100644 --- a/application/Router.php +++ b/application/Router.php @@ -31,6 +31,8 @@ class Router public static $PAGE_EDITLINK = 'edit_link'; + public static $PAGE_DELETELINK = 'delete_link'; + public static $PAGE_EXPORT = 'export'; public static $PAGE_IMPORT = 'import'; @@ -120,6 +122,10 @@ class Router return self::$PAGE_EDITLINK; } + if (isset($get['delete_link'])) { + return self::$PAGE_DELETELINK; + } + if (startsWith($query, 'do='. self::$PAGE_EXPORT)) { return self::$PAGE_EXPORT; } diff --git a/index.php b/index.php index a0a3a8c..34f0e38 100644 --- a/index.php +++ b/index.php @@ -1325,21 +1325,21 @@ function renderPage($conf, $pluginManager) } // -------- User clicked the "Delete" button when editing a link: Delete link from database. - if (isset($_POST['delete_link'])) + if ($targetPage == Router::$PAGE_DELETELINK) { - if (!tokenOk($_POST['token'])) die('Wrong token.'); - // We do not need to ask for confirmation: // - confirmation is handled by JavaScript // - we are protected from XSRF by the token. - // FIXME! We keep `lf_linkdate` for consistency before a proper API. To be removed. - $id = isset($_POST['lf_id']) ? intval(escape($_POST['lf_id'])) : intval(escape($_POST['lf_linkdate'])); - - $pluginManager->executeHooks('delete_link', $LINKSDB[$id]); + if (! tokenOk($_GET['token'])) { + die('Wrong token.'); + } + $id = intval(escape($_GET['lf_linkdate'])); + $link = $LINKSDB[$id]; + $pluginManager->executeHooks('delete_link', $link); unset($LINKSDB[$id]); - $LINKSDB->save('resource.page_cache'); // save to disk + $LINKSDB->save($conf->get('resource.page_cache')); // save to disk // If we are called from the bookmarklet, we must close the popup: if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { echo ''; exit; } diff --git a/tpl/linklist.html b/tpl/linklist.html index 0f1a5e8..d423234 100644 --- a/tpl/linklist.html +++ b/tpl/linklist.html @@ -84,7 +84,7 @@
-
+ From e350aa750f9e9e742bb60a1e04ebd9e21f763c78 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 18 Dec 2016 14:27:32 +0100 Subject: [PATCH 008/121] Fix typo in markdown plugin meta description --- plugins/markdown/markdown.meta | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/markdown/markdown.meta b/plugins/markdown/markdown.meta index 8df2ed0..322856e 100644 --- a/plugins/markdown/markdown.meta +++ b/plugins/markdown/markdown.meta @@ -1,4 +1,4 @@ description="Render shaare description with Markdown syntax.
Warning: -If your shaared descriptions containing HTML tags before enabling the markdown plugin, +If your shaared descriptions contained HTML tags before enabling the markdown plugin, enabling it might break your page. See the README." From 085efc33cc0cadaed0c01d926604e219e1d44365 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 2 Aug 2016 11:55:11 +0200 Subject: [PATCH 009/121] Add plugin placeholders in RSS and ATOM feeds templates --- tpl/feed.atom.html | 11 ++++++----- tpl/feed.rss.html | 10 ++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tpl/feed.atom.html b/tpl/feed.atom.html index aead045..2918750 100644 --- a/tpl/feed.atom.html +++ b/tpl/feed.atom.html @@ -6,11 +6,9 @@ {$last_update} {/if} - {if="!empty($pubsubhub_url)"} - - - - {/if} + {loop="$plugins_feed_header"} + {$value} + {/loop} {$index_url} {$index_url} @@ -34,6 +32,9 @@ {loop="$value.taglist"} {/loop} + {loop="$value.feed_plugins"} + {$value} + {/loop} {/loop} diff --git a/tpl/feed.rss.html b/tpl/feed.rss.html index e18dbf9..66d9a86 100644 --- a/tpl/feed.rss.html +++ b/tpl/feed.rss.html @@ -8,10 +8,9 @@ {$index_url} Shaarli - {if="!empty($pubsubhub_url)"} - - - {/if} + {loop="$plugins_feed_header"} + {$value} + {/loop} {loop="$links"} {$value.title} @@ -29,6 +28,9 @@ {loop="$value.taglist"} {$value} {/loop} + {loop="$value.feed_plugins"} + {$value} + {/loop} {/loop} From db90dfcbbc406b50381f17a72f24095fee91bb09 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 2 Aug 2016 11:55:49 +0200 Subject: [PATCH 010/121] Move PubSubHubbub code as a default plugin --- application/FeedBuilder.php | 16 ---- composer.json | 3 +- index.php | 5 -- plugins/pubsubhubbub/README.md | 20 +++++ plugins/pubsubhubbub/hub.atom.xml | 1 + plugins/pubsubhubbub/hub.rss.xml | 1 + plugins/pubsubhubbub/pubsubhubbub.meta | 2 + plugins/pubsubhubbub/pubsubhubbub.php | 101 +++++++++++++++++++++++ tests/FeedBuilderTest.php | 14 ---- tests/plugins/PluginPubsubhubbubTest.php | 54 ++++++++++++ tpl/feed.atom.html | 2 +- tpl/feed.rss.html | 2 +- 12 files changed, 183 insertions(+), 38 deletions(-) create mode 100644 plugins/pubsubhubbub/README.md create mode 100644 plugins/pubsubhubbub/hub.atom.xml create mode 100644 plugins/pubsubhubbub/hub.rss.xml create mode 100644 plugins/pubsubhubbub/pubsubhubbub.meta create mode 100644 plugins/pubsubhubbub/pubsubhubbub.php create mode 100644 tests/plugins/PluginPubsubhubbubTest.php diff --git a/application/FeedBuilder.php b/application/FeedBuilder.php index fedd90e..a1f4da4 100644 --- a/application/FeedBuilder.php +++ b/application/FeedBuilder.php @@ -62,11 +62,6 @@ class FeedBuilder */ protected $hideDates; - /** - * @var string PubSub hub URL. - */ - protected $pubsubhubUrl; - /** * @var string server locale. */ @@ -120,7 +115,6 @@ class FeedBuilder } $data['language'] = $this->getTypeLanguage(); - $data['pubsubhub_url'] = $this->pubsubhubUrl; $data['last_update'] = $this->getLatestDateFormatted(); $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; // Remove leading slash from REQUEST_URI. @@ -182,16 +176,6 @@ class FeedBuilder return $link; } - /** - * Assign PubSub hub URL. - * - * @param string $pubsubhubUrl PubSub hub url. - */ - public function setPubsubhubUrl($pubsubhubUrl) - { - $this->pubsubhubUrl = $pubsubhubUrl; - } - /** * Set this to true to use permalinks instead of direct links. * diff --git a/composer.json b/composer.json index 4786fe9..cfbde1a 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "php": ">=5.5", "shaarli/netscape-bookmark-parser": "1.*", "erusev/parsedown": "1.6", - "slim/slim": "^3.0" + "slim/slim": "^3.0", + "pubsubhubbub/publisher": "dev-master" }, "require-dev": { "phpmd/phpmd" : "@stable", diff --git a/index.php b/index.php index eb73941..dd9b48b 100644 --- a/index.php +++ b/index.php @@ -910,10 +910,6 @@ function renderPage($conf, $pluginManager, $LINKSDB) $feedGenerator->setLocale(strtolower(setlocale(LC_COLLATE, 0))); $feedGenerator->setHideDates($conf->get('privacy.hide_timestamps') && !isLoggedIn()); $feedGenerator->setUsePermalinks(isset($_GET['permalinks']) || !$conf->get('feed.rss_permalinks')); - $pshUrl = $conf->get('config.PUBSUBHUB_URL'); - if (!empty($pshUrl)) { - $feedGenerator->setPubsubhubUrl($pshUrl); - } $data = $feedGenerator->buildData(); // Process plugin hook. @@ -1289,7 +1285,6 @@ function renderPage($conf, $pluginManager, $LINKSDB) $LINKSDB[$id] = $link; $LINKSDB->save($conf->get('resource.page_cache')); - pubsubhub($conf); // If we are called from the bookmarklet, we must close the popup: if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { diff --git a/plugins/pubsubhubbub/README.md b/plugins/pubsubhubbub/README.md new file mode 100644 index 0000000..3a65492 --- /dev/null +++ b/plugins/pubsubhubbub/README.md @@ -0,0 +1,20 @@ +# PubSubHubbub plugin + +Enable this plugin to notify a Hub everytime you add or edit a link. + +This allow hub subcribers to receive update notifications in real time, +which is useful for feed syndication service which supports PubSubHubbub. + +## Public Hub + +By default, Shaarli will use [Google's public hub](http://pubsubhubbub.appspot.com/). + +[Here](https://github.com/pubsubhubbub/PubSubHubbub/wiki/Hubs) is a list of public hubs. + +You can also host your own PubSubHubbub server implementation, such as [phubb](https://github.com/cweiske/phubb). + +## cURL + +While there is a fallback function to notify the hub, it's recommended that +you have PHP cURL extension enabled to use this plugin. + diff --git a/plugins/pubsubhubbub/hub.atom.xml b/plugins/pubsubhubbub/hub.atom.xml new file mode 100644 index 0000000..24d93d3 --- /dev/null +++ b/plugins/pubsubhubbub/hub.atom.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/pubsubhubbub/hub.rss.xml b/plugins/pubsubhubbub/hub.rss.xml new file mode 100644 index 0000000..27bf67a --- /dev/null +++ b/plugins/pubsubhubbub/hub.rss.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/pubsubhubbub/pubsubhubbub.meta b/plugins/pubsubhubbub/pubsubhubbub.meta new file mode 100644 index 0000000..289f5cd --- /dev/null +++ b/plugins/pubsubhubbub/pubsubhubbub.meta @@ -0,0 +1,2 @@ +description="Enable PubSubHubbub feed publishing." +parameters="PUBSUBHUB_URL" diff --git a/plugins/pubsubhubbub/pubsubhubbub.php b/plugins/pubsubhubbub/pubsubhubbub.php new file mode 100644 index 0000000..03b6757 --- /dev/null +++ b/plugins/pubsubhubbub/pubsubhubbub.php @@ -0,0 +1,101 @@ +get('plugins.PUBSUBHUB_URL'); + if (empty($hub)) { + // Default hub. + $conf->set('plugins.PUBSUBHUB_URL', 'https://pubsubhubbub.appspot.com/'); + } +} + + +/** + * Render feed hook. + * Adds the hub URL in ATOM and RSS feed. + * + * @param array $data Template data. + * @param ConfigManager $conf instance. + * + * @return array updated template data. + */ +function hook_pubsubhubbub_render_feed($data, $conf) +{ + $feedType = $data['_PAGE_'] == Router::$PAGE_FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM; + $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml'); + $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL')); + + return $data; +} + +/** + * Save link hook. + * Publish to the hub when a link is saved. + * + * @param array $data Template data. + * @param ConfigManager $conf instance. + * + * @return array unaltered data. + */ +function hook_pubsubhubbub_save_link($data, $conf) +{ + $feeds = array( + index_url($_SERVER) .'?do=atom', + index_url($_SERVER) .'?do=rss', + ); + + $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post'; + try { + $p = new Publisher($conf->get('plugins.PUBSUBHUB_URL')); + $p->publish_update($feeds, $httpPost); + } catch (Exception $e) { + error_log('Could not publish to PubSubHubbub: ' . $e->getMessage()); + } + + return $data; +} + +/** + * Http function used to post to the hub endpoint without cURL extension. + * + * @param string $url Hub endpoint. + * @param string $postString String to POST. + * + * @return bool + * + * @throws Exception An error occurred. + */ +function nocurl_http_post($url, $postString) { + $params = array('http' => array( + 'method' => 'POST', + 'content' => $postString, + 'user_agent' => 'PubSubHubbub-Publisher-PHP/1.0', + )); + + $context = stream_context_create($params); + $fp = @fopen($url, 'rb', false, $context); + if (!$fp) { + throw new Exception('Could not post to '. $url); + } + $response = @stream_get_contents($fp); + if ($response === false) { + throw new Exception('Bad response from the hub '. $url); + } + return $response; +} diff --git a/tests/FeedBuilderTest.php b/tests/FeedBuilderTest.php index 06a4450..a590306 100644 --- a/tests/FeedBuilderTest.php +++ b/tests/FeedBuilderTest.php @@ -75,7 +75,6 @@ class FeedBuilderTest extends PHPUnit_Framework_TestCase $data = $feedBuilder->buildData(); // Test headers (RSS) $this->assertEquals(self::$RSS_LANGUAGE, $data['language']); - $this->assertEmpty($data['pubsubhub_url']); $this->assertRegExp('/Wed, 03 Aug 2016 09:30:33 \+\d{4}/', $data['last_update']); $this->assertEquals(true, $data['show_dates']); $this->assertEquals('http://host.tld/index.php?do=feed', $data['self_link']); @@ -210,19 +209,6 @@ class FeedBuilderTest extends PHPUnit_Framework_TestCase $this->assertTrue($data['show_dates']); } - /** - * Test buildData with hide dates settings. - */ - public function testBuildDataPubsubhub() - { - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false); - $feedBuilder->setLocale(self::$LOCALE); - $feedBuilder->setPubsubhubUrl('http://pubsubhub.io'); - $data = $feedBuilder->buildData(); - $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); - $this->assertEquals('http://pubsubhub.io', $data['pubsubhub_url']); - } - /** * Test buildData when Shaarli is served from a subdirectory */ diff --git a/tests/plugins/PluginPubsubhubbubTest.php b/tests/plugins/PluginPubsubhubbubTest.php new file mode 100644 index 0000000..24dd7a1 --- /dev/null +++ b/tests/plugins/PluginPubsubhubbubTest.php @@ -0,0 +1,54 @@ +set('plugins.PUBSUBHUB_URL', $hub); + $data['_PAGE_'] = Router::$PAGE_FEED_RSS; + + $data = hook_pubsubhubbub_render_feed($data, $conf); + $expected = ''; + $this->assertEquals($expected, $data['feed_plugins_header'][0]); + } + + /** + * Test render_feed hook with an ATOM feed. + */ + function testPubSubAtomRenderFeed() + { + $hub = 'http://domain.hub'; + $conf = new ConfigManager(self::$configFile); + $conf->set('plugins.PUBSUBHUB_URL', $hub); + $data['_PAGE_'] = Router::$PAGE_FEED_ATOM; + + $data = hook_pubsubhubbub_render_feed($data, $conf); + $expected = ''; + $this->assertEquals($expected, $data['feed_plugins_header'][0]); + } +} diff --git a/tpl/feed.atom.html b/tpl/feed.atom.html index 2918750..ad7dd93 100644 --- a/tpl/feed.atom.html +++ b/tpl/feed.atom.html @@ -6,7 +6,7 @@ {$last_update} {/if} - {loop="$plugins_feed_header"} + {loop="$feed_plugins_header"} {$value} {/loop} diff --git a/tpl/feed.rss.html b/tpl/feed.rss.html index 66d9a86..73791f6 100644 --- a/tpl/feed.rss.html +++ b/tpl/feed.rss.html @@ -8,7 +8,7 @@ {$index_url} Shaarli - {loop="$plugins_feed_header"} + {loop="$feed_plugins_header"} {$value} {/loop} {loop="$links"} From 465b1c4090c4a83f11b2bb30405037fb0aa4cf8c Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 2 Jan 2017 18:37:08 +0100 Subject: [PATCH 011/121] API: fix Slim namespaces Signed-off-by: VirtualTam --- index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.php b/index.php index dd9b48b..a970e29 100644 --- a/index.php +++ b/index.php @@ -2231,8 +2231,8 @@ $app = new \Slim\App($container); // REST API routes $app->group('/api/v1', function() { - $this->get('/info', '\Api\Controllers\Info:getInfo'); -})->add('\Api\ApiMiddleware'); + $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo'); +})->add('\Shaarli\Api\ApiMiddleware'); $response = $app->run(true); // Hack to make Slim and Shaarli router work together: From b3051a6aae446e063c3b6fa4a6a600357a9f24af Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 20 Dec 2016 11:06:22 +0100 Subject: [PATCH 012/121] Fixes presence of empty tags for private tags and in search results * Private tags: make sure empty tags are properly filtered * Search results: * Use preg_split instead of function combination * Add normalize_spaces to remove extra whitespaces displaying empty tags search --- application/LinkFilter.php | 2 +- application/Utils.php | 13 +++++++++++++ index.php | 6 +++--- tests/UtilsTest.php | 14 +++++++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/application/LinkFilter.php b/application/LinkFilter.php index daa6d9c..57ebfd5 100644 --- a/application/LinkFilter.php +++ b/application/LinkFilter.php @@ -348,7 +348,7 @@ class LinkFilter $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); $tagsOut = str_replace(',', ' ', $tagsOut); - return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen')); + return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); } } diff --git a/application/Utils.php b/application/Utils.php index 6290234..35d6522 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -257,3 +257,16 @@ function generate_api_secret($username, $salt) return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12)); } + +/** + * Trim string, replace sequences of whitespaces by a single space. + * PHP equivalent to `normalize-space` XSLT function. + * + * @param string $string Input string. + * + * @return mixed Normalized string. + */ +function normalize_spaces($string) +{ + return preg_replace('/\s{2,}/', ' ', trim($string)); +} diff --git a/index.php b/index.php index dd9b48b..427eb23 100644 --- a/index.php +++ b/index.php @@ -1601,8 +1601,8 @@ function renderPage($conf, $pluginManager, $LINKSDB) function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager) { // Used in templates - $searchtags = !empty($_GET['searchtags']) ? escape($_GET['searchtags']) : ''; - $searchterm = !empty($_GET['searchterm']) ? escape($_GET['searchterm']) : ''; + $searchtags = !empty($_GET['searchtags']) ? escape(normalize_spaces($_GET['searchtags'])) : ''; + $searchterm = !empty($_GET['searchterm']) ? escape(normalize_spaces($_GET['searchterm'])) : ''; // Smallhash filter if (! empty($_SERVER['QUERY_STRING']) @@ -1649,7 +1649,7 @@ function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager) } else { $link['updated_timestamp'] = ''; } - $taglist = explode(' ', $link['tags']); + $taglist = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); uasort($taglist, 'strcasecmp'); $link['taglist'] = $taglist; // Check for both signs of a note: starting with ? and 7 chars long. diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index 0cf9a92..c885f55 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -253,7 +253,7 @@ class UtilsTest extends PHPUnit_Framework_TestCase is_session_id_valid('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=') ); } - + /** * Test generateSecretApi. */ @@ -270,4 +270,16 @@ class UtilsTest extends PHPUnit_Framework_TestCase $this->assertFalse(generate_api_secret('', '')); $this->assertFalse(generate_api_secret(false, false)); } + + /** + * Test normalize_spaces. + */ + public function testNormalizeSpace() + { + $str = ' foo bar is important '; + $this->assertEquals('foo bar is important', normalize_spaces($str)); + $this->assertEquals('foo', normalize_spaces('foo')); + $this->assertEquals('', normalize_spaces('')); + $this->assertEquals(null, normalize_spaces(null)); + } } From af815f771cbed43c3dd30436f23061c66e92e5a3 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Fri, 16 Dec 2016 14:03:42 +0100 Subject: [PATCH 013/121] Add opensearch to RSS and ATOM feeds Fixes #709 --- tpl/feed.atom.html | 2 ++ tpl/feed.rss.html | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tpl/feed.atom.html b/tpl/feed.atom.html index ad7dd93..49798e8 100644 --- a/tpl/feed.atom.html +++ b/tpl/feed.atom.html @@ -6,6 +6,8 @@ {$last_update} {/if} + {loop="$feed_plugins_header"} {$value} {/loop} diff --git a/tpl/feed.rss.html b/tpl/feed.rss.html index 73791f6..ee3fef8 100644 --- a/tpl/feed.rss.html +++ b/tpl/feed.rss.html @@ -8,6 +8,8 @@ {$index_url} Shaarli +